Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b68c0b0737 | |||
| 1920b47924 | |||
| 857b1462e3 | |||
| 813aa0faf9 | |||
| 75bb7abebc | |||
| bb46b26ec6 | |||
| 8d22669bef | |||
| fb0b3df794 | |||
| 48ae48a165 | |||
| a190667320 | |||
| cfdca04df9 | |||
| a28e3724ae | |||
| 42d00dd1c0 | |||
| 8928915947 | |||
| cfd37ca526 |
@@ -475,6 +475,17 @@ Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
|
|||||||
> Users upgrade their Docker containers but keep their existing DB.
|
> Users upgrade their Docker containers but keep their existing DB.
|
||||||
> The app must NOT crash if old columns are missing.
|
> The app must NOT crash if old columns are missing.
|
||||||
|
|
||||||
|
### ⚠️ MANDATORY for EVERY New Feature
|
||||||
|
|
||||||
|
**Before implementing ANY feature that touches user data or settings:**
|
||||||
|
|
||||||
|
1. **Check if new DB columns are needed** - Does the feature require storing new data?
|
||||||
|
2. **If YES → Follow ALL steps below** - Schema.ts + Drizzle migration + ALTER migration + NULL-safe code
|
||||||
|
3. **NEVER skip the ALTER migration** - This is the #1 cause of production 500 errors!
|
||||||
|
|
||||||
|
**Common mistake:** Adding a column to `schema.ts` and forgetting the ALTER migration in `client.ts`.
|
||||||
|
The Drizzle migration only works for NEW databases. Existing production databases need the ALTER migration!
|
||||||
|
|
||||||
### Schema Management with Drizzle Kit
|
### Schema Management with Drizzle Kit
|
||||||
|
|
||||||
The database schema uses **Drizzle Kit** for migrations. There is a **single source of truth**:
|
The database schema uses **Drizzle Kit** for migrations. There is a **single source of truth**:
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `medications` ADD `stock_adjustment` integer DEFAULT 0 NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `medications` ADD `last_stock_correction_at` integer;
|
||||||
@@ -0,0 +1,827 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "bcb60728-38c0-4965-adac-829c02240d89",
|
||||||
|
"prevId": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
|
||||||
|
"tables": {
|
||||||
|
"dose_tracking": {
|
||||||
|
"name": "dose_tracking",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dose_id": {
|
||||||
|
"name": "dose_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_at": {
|
||||||
|
"name": "taken_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
},
|
||||||
|
"marked_by": {
|
||||||
|
"name": "marked_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dismissed": {
|
||||||
|
"name": "dismissed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"dose_tracking_user_id_users_id_fk": {
|
||||||
|
"name": "dose_tracking_user_id_users_id_fk",
|
||||||
|
"tableFrom": "dose_tracking",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"medications": {
|
||||||
|
"name": "medications",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"generic_name": {
|
||||||
|
"name": "generic_name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by_json": {
|
||||||
|
"name": "taken_by_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"pack_count": {
|
||||||
|
"name": "pack_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"blisters_per_pack": {
|
||||||
|
"name": "blisters_per_pack",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"pills_per_blister": {
|
||||||
|
"name": "pills_per_blister",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"loose_tablets": {
|
||||||
|
"name": "loose_tablets",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"stock_adjustment": {
|
||||||
|
"name": "stock_adjustment",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"pill_weight_mg": {
|
||||||
|
"name": "pill_weight_mg",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"usage_json": {
|
||||||
|
"name": "usage_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"every_json": {
|
||||||
|
"name": "every_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"start_json": {
|
||||||
|
"name": "start_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expiry_date": {
|
||||||
|
"name": "expiry_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"intake_reminders_enabled": {
|
||||||
|
"name": "intake_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"medications_user_id_users_id_fk": {
|
||||||
|
"name": "medications_user_id_users_id_fk",
|
||||||
|
"tableFrom": "medications",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refill_history": {
|
||||||
|
"name": "refill_history",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"medication_id": {
|
||||||
|
"name": "medication_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"packs_added": {
|
||||||
|
"name": "packs_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"loose_pills_added": {
|
||||||
|
"name": "loose_pills_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"refill_date": {
|
||||||
|
"name": "refill_date",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refill_history_medication_id_medications_id_fk": {
|
||||||
|
"name": "refill_history_medication_id_medications_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "medications",
|
||||||
|
"columnsFrom": [
|
||||||
|
"medication_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"refill_history_user_id_users_id_fk": {
|
||||||
|
"name": "refill_history_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refresh_tokens": {
|
||||||
|
"name": "refresh_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_id": {
|
||||||
|
"name": "token_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rotated_at": {
|
||||||
|
"name": "rotated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revoked": {
|
||||||
|
"name": "revoked",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"refresh_tokens_token_id_unique": {
|
||||||
|
"name": "refresh_tokens_token_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"token_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refresh_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "refresh_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refresh_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"share_tokens": {
|
||||||
|
"name": "share_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by": {
|
||||||
|
"name": "taken_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"schedule_days": {
|
||||||
|
"name": "schedule_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"share_tokens_token_unique": {
|
||||||
|
"name": "share_tokens_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"share_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "share_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "share_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"name": "user_settings",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_enabled": {
|
||||||
|
"name": "email_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notification_email": {
|
||||||
|
"name": "notification_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_stock_reminders": {
|
||||||
|
"name": "email_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"email_intake_reminders": {
|
||||||
|
"name": "email_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_enabled": {
|
||||||
|
"name": "shoutrrr_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"shoutrrr_url": {
|
||||||
|
"name": "shoutrrr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"shoutrrr_stock_reminders": {
|
||||||
|
"name": "shoutrrr_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_intake_reminders": {
|
||||||
|
"name": "shoutrrr_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"reminder_days_before": {
|
||||||
|
"name": "reminder_days_before",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 7
|
||||||
|
},
|
||||||
|
"repeat_daily_reminders": {
|
||||||
|
"name": "repeat_daily_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"skip_reminders_for_taken_doses": {
|
||||||
|
"name": "skip_reminders_for_taken_doses",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"repeat_reminders_enabled": {
|
||||||
|
"name": "repeat_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"reminder_repeat_interval_minutes": {
|
||||||
|
"name": "reminder_repeat_interval_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"max_nagging_reminders": {
|
||||||
|
"name": "max_nagging_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"low_stock_days": {
|
||||||
|
"name": "low_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"normal_stock_days": {
|
||||||
|
"name": "normal_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"high_stock_days": {
|
||||||
|
"name": "high_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 180
|
||||||
|
},
|
||||||
|
"expiry_warning_days": {
|
||||||
|
"name": "expiry_warning_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'en'"
|
||||||
|
},
|
||||||
|
"stock_calculation_mode": {
|
||||||
|
"name": "stock_calculation_mode",
|
||||||
|
"type": "text(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'automatic'"
|
||||||
|
},
|
||||||
|
"last_auto_email_sent": {
|
||||||
|
"name": "last_auto_email_sent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_type": {
|
||||||
|
"name": "last_notification_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_channel": {
|
||||||
|
"name": "last_notification_channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_settings_user_id_unique": {
|
||||||
|
"name": "user_settings_user_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_settings_user_id_users_id_fk": {
|
||||||
|
"name": "user_settings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_settings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auth_provider": {
|
||||||
|
"name": "auth_provider",
|
||||||
|
"type": "text(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'local'"
|
||||||
|
},
|
||||||
|
"oidc_subject": {
|
||||||
|
"name": "oidc_subject",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"last_login_at": {
|
||||||
|
"name": "last_login_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,834 @@
|
|||||||
|
{
|
||||||
|
"version": "6",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "098ee506-e43d-4ccb-bee5-c387905695ab",
|
||||||
|
"prevId": "bcb60728-38c0-4965-adac-829c02240d89",
|
||||||
|
"tables": {
|
||||||
|
"dose_tracking": {
|
||||||
|
"name": "dose_tracking",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dose_id": {
|
||||||
|
"name": "dose_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_at": {
|
||||||
|
"name": "taken_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
},
|
||||||
|
"marked_by": {
|
||||||
|
"name": "marked_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"dismissed": {
|
||||||
|
"name": "dismissed",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"dose_tracking_user_id_users_id_fk": {
|
||||||
|
"name": "dose_tracking_user_id_users_id_fk",
|
||||||
|
"tableFrom": "dose_tracking",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"medications": {
|
||||||
|
"name": "medications",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"generic_name": {
|
||||||
|
"name": "generic_name",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by_json": {
|
||||||
|
"name": "taken_by_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"pack_count": {
|
||||||
|
"name": "pack_count",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"blisters_per_pack": {
|
||||||
|
"name": "blisters_per_pack",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"pills_per_blister": {
|
||||||
|
"name": "pills_per_blister",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 1
|
||||||
|
},
|
||||||
|
"loose_tablets": {
|
||||||
|
"name": "loose_tablets",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"stock_adjustment": {
|
||||||
|
"name": "stock_adjustment",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"last_stock_correction_at": {
|
||||||
|
"name": "last_stock_correction_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"pill_weight_mg": {
|
||||||
|
"name": "pill_weight_mg",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"usage_json": {
|
||||||
|
"name": "usage_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"every_json": {
|
||||||
|
"name": "every_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"start_json": {
|
||||||
|
"name": "start_json",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'[]'"
|
||||||
|
},
|
||||||
|
"image_url": {
|
||||||
|
"name": "image_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expiry_date": {
|
||||||
|
"name": "expiry_date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"intake_reminders_enabled": {
|
||||||
|
"name": "intake_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"medications_user_id_users_id_fk": {
|
||||||
|
"name": "medications_user_id_users_id_fk",
|
||||||
|
"tableFrom": "medications",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refill_history": {
|
||||||
|
"name": "refill_history",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"medication_id": {
|
||||||
|
"name": "medication_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"packs_added": {
|
||||||
|
"name": "packs_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"loose_pills_added": {
|
||||||
|
"name": "loose_pills_added",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"refill_date": {
|
||||||
|
"name": "refill_date",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "(strftime('%s','now'))"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refill_history_medication_id_medications_id_fk": {
|
||||||
|
"name": "refill_history_medication_id_medications_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "medications",
|
||||||
|
"columnsFrom": [
|
||||||
|
"medication_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"refill_history_user_id_users_id_fk": {
|
||||||
|
"name": "refill_history_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refill_history",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"refresh_tokens": {
|
||||||
|
"name": "refresh_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token_id": {
|
||||||
|
"name": "token_id",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"rotated_at": {
|
||||||
|
"name": "rotated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"revoked": {
|
||||||
|
"name": "revoked",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"refresh_tokens_token_id_unique": {
|
||||||
|
"name": "refresh_tokens_token_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"token_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"refresh_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "refresh_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "refresh_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"share_tokens": {
|
||||||
|
"name": "share_tokens",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text(64)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"taken_by": {
|
||||||
|
"name": "taken_by",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"schedule_days": {
|
||||||
|
"name": "schedule_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"share_tokens_token_unique": {
|
||||||
|
"name": "share_tokens_token_unique",
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"share_tokens_user_id_users_id_fk": {
|
||||||
|
"name": "share_tokens_user_id_users_id_fk",
|
||||||
|
"tableFrom": "share_tokens",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"user_settings": {
|
||||||
|
"name": "user_settings",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_enabled": {
|
||||||
|
"name": "email_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"notification_email": {
|
||||||
|
"name": "notification_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email_stock_reminders": {
|
||||||
|
"name": "email_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"email_intake_reminders": {
|
||||||
|
"name": "email_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_enabled": {
|
||||||
|
"name": "shoutrrr_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"shoutrrr_url": {
|
||||||
|
"name": "shoutrrr_url",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"shoutrrr_stock_reminders": {
|
||||||
|
"name": "shoutrrr_stock_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"shoutrrr_intake_reminders": {
|
||||||
|
"name": "shoutrrr_intake_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"reminder_days_before": {
|
||||||
|
"name": "reminder_days_before",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 7
|
||||||
|
},
|
||||||
|
"repeat_daily_reminders": {
|
||||||
|
"name": "repeat_daily_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"skip_reminders_for_taken_doses": {
|
||||||
|
"name": "skip_reminders_for_taken_doses",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"repeat_reminders_enabled": {
|
||||||
|
"name": "repeat_reminders_enabled",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"reminder_repeat_interval_minutes": {
|
||||||
|
"name": "reminder_repeat_interval_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"max_nagging_reminders": {
|
||||||
|
"name": "max_nagging_reminders",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 5
|
||||||
|
},
|
||||||
|
"low_stock_days": {
|
||||||
|
"name": "low_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 30
|
||||||
|
},
|
||||||
|
"normal_stock_days": {
|
||||||
|
"name": "normal_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"high_stock_days": {
|
||||||
|
"name": "high_stock_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 180
|
||||||
|
},
|
||||||
|
"expiry_warning_days": {
|
||||||
|
"name": "expiry_warning_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"name": "language",
|
||||||
|
"type": "text(10)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'en'"
|
||||||
|
},
|
||||||
|
"stock_calculation_mode": {
|
||||||
|
"name": "stock_calculation_mode",
|
||||||
|
"type": "text(20)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'automatic'"
|
||||||
|
},
|
||||||
|
"last_auto_email_sent": {
|
||||||
|
"name": "last_auto_email_sent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_type": {
|
||||||
|
"name": "last_notification_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"last_notification_channel": {
|
||||||
|
"name": "last_notification_channel",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"user_settings_user_id_unique": {
|
||||||
|
"name": "user_settings_user_id_unique",
|
||||||
|
"columns": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {
|
||||||
|
"user_settings_user_id_users_id_fk": {
|
||||||
|
"name": "user_settings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "user_settings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": true
|
||||||
|
},
|
||||||
|
"username": {
|
||||||
|
"name": "username",
|
||||||
|
"type": "text(100)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password_hash": {
|
||||||
|
"name": "password_hash",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"avatar_url": {
|
||||||
|
"name": "avatar_url",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"auth_provider": {
|
||||||
|
"name": "auth_provider",
|
||||||
|
"type": "text(50)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'local'"
|
||||||
|
},
|
||||||
|
"oidc_subject": {
|
||||||
|
"name": "oidc_subject",
|
||||||
|
"type": "text(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"last_login_at": {
|
||||||
|
"name": "last_login_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "CURRENT_TIMESTAMP"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_username_unique": {
|
||||||
|
"name": "users_username_unique",
|
||||||
|
"columns": [
|
||||||
|
"username"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"checkConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"views": {},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
},
|
||||||
|
"internal": {
|
||||||
|
"indexes": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,20 @@
|
|||||||
"when": 1768600500759,
|
"when": 1768600500759,
|
||||||
"tag": "0000_init",
|
"tag": "0000_init",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768734577830,
|
||||||
|
"tag": "0001_add_stock_adjustment",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1768736677092,
|
||||||
|
"tag": "0002_add_last_stock_correction_at",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.1.0",
|
"version": "1.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -73,6 +73,12 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
|
||||||
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
// Added in v1.2.3 - dismiss missed doses without deducting stock
|
||||||
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
|
||||||
|
// Added in v1.3.x - stock calculation mode (automatic/manual)
|
||||||
|
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
|
||||||
|
// Added for stock correction - hidden offset that doesn't affect looseTablets
|
||||||
|
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
|
||||||
|
// Added for stock correction - timestamp to ignore consumed doses before correction
|
||||||
|
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const sql of alterMigrations) {
|
for (const sql of alterMigrations) {
|
||||||
@@ -86,6 +92,30 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create tables that might be missing (silently fail if already exists)
|
||||||
|
const createTableMigrations = [
|
||||||
|
// Added in v1.3.x - refill history tracking
|
||||||
|
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
packs_added INTEGER NOT NULL DEFAULT 0,
|
||||||
|
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||||
|
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
|
)`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sql of createTableMigrations) {
|
||||||
|
try {
|
||||||
|
await client.execute(sql);
|
||||||
|
} catch (e: any) {
|
||||||
|
// Silently ignore "table already exists" errors
|
||||||
|
if (!e.message?.includes("already exists")) {
|
||||||
|
errors.push(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return { success: errors.length === 0, errors };
|
return { success: errors.length === 0, errors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ export const medications = sqliteTable("medications", {
|
|||||||
packCount: integer("pack_count").notNull().default(1),
|
packCount: integer("pack_count").notNull().default(1),
|
||||||
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
|
||||||
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
|
||||||
looseTablets: integer("loose_tablets").notNull().default(0),
|
looseTablets: integer("loose_tablets").notNull().default(0), // TRUE loose pills (user-entered)
|
||||||
|
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
|
||||||
|
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
|
||||||
pillWeightMg: integer("pill_weight_mg"),
|
pillWeightMg: integer("pill_weight_mg"),
|
||||||
usageJson: text("usage_json").notNull().default("[]"),
|
usageJson: text("usage_json").notNull().default("[]"),
|
||||||
everyJson: text("every_json").notNull().default("[]"),
|
everyJson: text("every_json").notNull().default("[]"),
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const inventorySchema = z.object({
|
|||||||
blistersPerPack: z.number().int().min(1).default(1),
|
blistersPerPack: z.number().int().min(1).default(1),
|
||||||
pillsPerBlister: z.number().int().min(1).default(1),
|
pillsPerBlister: z.number().int().min(1).default(1),
|
||||||
looseTablets: z.number().int().min(0).default(0),
|
looseTablets: z.number().int().min(0).default(0),
|
||||||
|
stockAdjustment: z.number().int().default(0), // Manual stock correction
|
||||||
});
|
});
|
||||||
|
|
||||||
const medicationExportSchema = z.object({
|
const medicationExportSchema = z.object({
|
||||||
@@ -47,6 +48,7 @@ const medicationExportSchema = z.object({
|
|||||||
notes: z.string().nullable().optional(),
|
notes: z.string().nullable().optional(),
|
||||||
intakeRemindersEnabled: z.boolean().default(false),
|
intakeRemindersEnabled: z.boolean().default(false),
|
||||||
image: z.string().nullable().optional(), // base64 data URL or null
|
image: z.string().nullable().optional(), // base64 data URL or null
|
||||||
|
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
|
||||||
});
|
});
|
||||||
|
|
||||||
const doseHistorySchema = z.object({
|
const doseHistorySchema = z.object({
|
||||||
@@ -55,6 +57,8 @@ const doseHistorySchema = z.object({
|
|||||||
scheduledTime: z.string(), // ISO datetime
|
scheduledTime: z.string(), // ISO datetime
|
||||||
takenAt: z.string(), // ISO datetime
|
takenAt: z.string(), // ISO datetime
|
||||||
markedBy: z.string().nullable().optional(),
|
markedBy: z.string().nullable().optional(),
|
||||||
|
dismissed: z.boolean().default(false),
|
||||||
|
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||||
});
|
});
|
||||||
|
|
||||||
const shareLinkSchema = z.object({
|
const shareLinkSchema = z.object({
|
||||||
@@ -204,8 +208,8 @@ function base64ToImage(base64: string, medicationId: number): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse dose ID to extract medication ID and timestamp
|
// Parse dose ID to extract medication ID and timestamp
|
||||||
// Format: "{medicationId}-{blisterIndex}-{timestampMs}"
|
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
|
||||||
function parseDoseId(doseId: string): { medicationId: number; blisterIndex: number; timestampMs: number } | null {
|
function parseDoseId(doseId: string): { medicationId: number; blisterIndex: number; timestampMs: number; person: string | null } | null {
|
||||||
const parts = doseId.split("-");
|
const parts = doseId.split("-");
|
||||||
if (parts.length < 3) return null;
|
if (parts.length < 3) return null;
|
||||||
|
|
||||||
@@ -215,12 +219,16 @@ function parseDoseId(doseId: string): { medicationId: number; blisterIndex: numb
|
|||||||
|
|
||||||
if (isNaN(medicationId) || isNaN(blisterIndex) || isNaN(timestampMs)) return null;
|
if (isNaN(medicationId) || isNaN(blisterIndex) || isNaN(timestampMs)) return null;
|
||||||
|
|
||||||
return { medicationId, blisterIndex, timestampMs };
|
// Check if there's a person suffix (4th part onwards, could be multi-part name)
|
||||||
|
const person = parts.length > 3 ? parts.slice(3).join("-") : null;
|
||||||
|
|
||||||
|
return { medicationId, blisterIndex, timestampMs, person };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build dose ID from parts
|
// Build dose ID from parts (with optional person suffix)
|
||||||
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number): string {
|
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number, person?: string | null): string {
|
||||||
return `${medicationId}-${blisterIndex}-${timestampMs}`;
|
const base = `${medicationId}-${blisterIndex}-${timestampMs}`;
|
||||||
|
return person ? `${base}-${person}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -233,11 +241,12 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /export - Export all user data
|
// GET /export - Export all user data
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.get<{ Querystring: { includeSensitive?: string } }>(
|
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>(
|
||||||
"/export",
|
"/export",
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
const includeSensitive = request.query.includeSensitive === "true";
|
const includeSensitive = request.query.includeSensitive === "true";
|
||||||
|
const includeImages = request.query.includeImages !== "false"; // Default to true
|
||||||
|
|
||||||
// 1. Load all medications
|
// 1. Load all medications
|
||||||
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
|
||||||
@@ -248,6 +257,21 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
const exportId = `med-${index + 1}`;
|
const exportId = `med-${index + 1}`;
|
||||||
medIdToExportId.set(med.id, exportId);
|
medIdToExportId.set(med.id, exportId);
|
||||||
|
|
||||||
|
// Safely convert lastStockCorrectionAt to ISO string
|
||||||
|
let lastStockCorrectionAtIso: string | null = null;
|
||||||
|
if (med.lastStockCorrectionAt) {
|
||||||
|
try {
|
||||||
|
if (med.lastStockCorrectionAt instanceof Date && !isNaN(med.lastStockCorrectionAt.getTime())) {
|
||||||
|
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
|
||||||
|
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
|
||||||
|
const d = new Date(med.lastStockCorrectionAt);
|
||||||
|
lastStockCorrectionAtIso = !isNaN(d.getTime()) ? d.toISOString() : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
lastStockCorrectionAtIso = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
_exportId: exportId,
|
_exportId: exportId,
|
||||||
name: med.name,
|
name: med.name,
|
||||||
@@ -258,13 +282,15 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: med.blistersPerPack ?? 1,
|
blistersPerPack: med.blistersPerPack ?? 1,
|
||||||
pillsPerBlister: med.pillsPerBlister ?? 1,
|
pillsPerBlister: med.pillsPerBlister ?? 1,
|
||||||
looseTablets: med.looseTablets ?? 0,
|
looseTablets: med.looseTablets ?? 0,
|
||||||
|
stockAdjustment: med.stockAdjustment ?? 0,
|
||||||
},
|
},
|
||||||
pillWeightMg: med.pillWeightMg,
|
pillWeightMg: med.pillWeightMg,
|
||||||
schedules: parseBlistersForExport(med),
|
schedules: parseBlistersForExport(med),
|
||||||
expiryDate: med.expiryDate,
|
expiryDate: med.expiryDate,
|
||||||
notes: med.notes,
|
notes: med.notes,
|
||||||
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
|
||||||
image: imageToBase64(med.imageUrl),
|
image: includeImages ? imageToBase64(med.imageUrl) : null,
|
||||||
|
lastStockCorrectionAt: lastStockCorrectionAtIso,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -278,12 +304,38 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
const exportId = medIdToExportId.get(parsed.medicationId);
|
const exportId = medIdToExportId.get(parsed.medicationId);
|
||||||
if (!exportId) return null; // Orphaned dose, skip
|
if (!exportId) return null; // Orphaned dose, skip
|
||||||
|
|
||||||
|
// Safely convert takenAt to ISO string
|
||||||
|
let takenAtIso: string;
|
||||||
|
try {
|
||||||
|
if (dose.takenAt instanceof Date && !isNaN(dose.takenAt.getTime())) {
|
||||||
|
takenAtIso = dose.takenAt.toISOString();
|
||||||
|
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
|
||||||
|
const d = new Date(dose.takenAt);
|
||||||
|
takenAtIso = !isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||||
|
} else {
|
||||||
|
takenAtIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
takenAtIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safely convert scheduled time
|
||||||
|
let scheduledTimeIso: string;
|
||||||
|
try {
|
||||||
|
const d = new Date(parsed.timestampMs);
|
||||||
|
scheduledTimeIso = !isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
|
||||||
|
} catch {
|
||||||
|
scheduledTimeIso = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
medicationRef: exportId,
|
medicationRef: exportId,
|
||||||
scheduleIndex: parsed.blisterIndex,
|
scheduleIndex: parsed.blisterIndex,
|
||||||
scheduledTime: new Date(parsed.timestampMs).toISOString(),
|
scheduledTime: scheduledTimeIso,
|
||||||
takenAt: dose.takenAt?.toISOString() ?? new Date().toISOString(),
|
takenAt: takenAtIso,
|
||||||
markedBy: dose.markedBy,
|
markedBy: dose.markedBy,
|
||||||
|
dismissed: dose.dismissed ?? false,
|
||||||
|
takenByPerson: parsed.person,
|
||||||
};
|
};
|
||||||
}).filter((d): d is NonNullable<typeof d> => d !== null);
|
}).filter((d): d is NonNullable<typeof d> => d !== null);
|
||||||
|
|
||||||
@@ -316,12 +368,29 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
// 4. Load share links
|
// 4. Load share links
|
||||||
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
|
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
|
||||||
|
|
||||||
const exportShareLinks = shares.map((share) => ({
|
const exportShareLinks = shares.map((share) => {
|
||||||
takenBy: share.takenBy,
|
// Safely convert expiresAt to ISO string
|
||||||
scheduleDays: share.scheduleDays,
|
let expiresAtIso: string | null = null;
|
||||||
expiresAt: share.expiresAt?.toISOString() ?? null,
|
if (share.expiresAt) {
|
||||||
regenerateToken: true, // Always regenerate tokens on import for security
|
try {
|
||||||
}));
|
if (share.expiresAt instanceof Date && !isNaN(share.expiresAt.getTime())) {
|
||||||
|
expiresAtIso = share.expiresAt.toISOString();
|
||||||
|
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
|
||||||
|
const d = new Date(share.expiresAt);
|
||||||
|
expiresAtIso = !isNaN(d.getTime()) ? d.toISOString() : null;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
expiresAtIso = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
takenBy: share.takenBy,
|
||||||
|
scheduleDays: share.scheduleDays,
|
||||||
|
expiresAt: expiresAtIso,
|
||||||
|
regenerateToken: true, // Always regenerate tokens on import for security
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Build export object
|
// Build export object
|
||||||
const exportData = {
|
const exportData = {
|
||||||
@@ -348,6 +417,13 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
app.post(
|
app.post(
|
||||||
"/import",
|
"/import",
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
// Increase body limit to 50MB to handle exports with base64 images
|
||||||
|
rawBody: true,
|
||||||
|
},
|
||||||
|
bodyLimit: 50 * 1024 * 1024, // 50 MB
|
||||||
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const userId = await getUserId(request, reply);
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
@@ -404,6 +480,8 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: med.inventory.blistersPerPack,
|
blistersPerPack: med.inventory.blistersPerPack,
|
||||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||||
looseTablets: med.inventory.looseTablets,
|
looseTablets: med.inventory.looseTablets,
|
||||||
|
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||||
pillWeightMg: med.pillWeightMg || null,
|
pillWeightMg: med.pillWeightMg || null,
|
||||||
usageJson,
|
usageJson,
|
||||||
everyJson,
|
everyJson,
|
||||||
@@ -435,13 +513,15 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Convert ISO timestamp back to milliseconds for dose ID
|
// Convert ISO timestamp back to milliseconds for dose ID
|
||||||
const timestampMs = new Date(dose.scheduledTime).getTime();
|
const timestampMs = new Date(dose.scheduledTime).getTime();
|
||||||
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs);
|
// Rebuild dose ID with optional person suffix
|
||||||
|
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
|
||||||
|
|
||||||
await db.insert(doseTracking).values({
|
await db.insert(doseTracking).values({
|
||||||
userId,
|
userId,
|
||||||
doseId,
|
doseId,
|
||||||
takenAt: new Date(dose.takenAt),
|
takenAt: new Date(dose.takenAt),
|
||||||
markedBy: dose.markedBy || null,
|
markedBy: dose.markedBy || null,
|
||||||
|
dismissed: dose.dismissed ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { FastifyInstance } from "fastify";
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import { resolve, dirname } from "path";
|
||||||
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
|
// Read version from package.json at startup
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const packageJsonPath = resolve(__dirname, "../../package.json");
|
||||||
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||||
|
const backendVersion = packageJson.version || "unknown";
|
||||||
|
|
||||||
export async function healthRoutes(app: FastifyInstance) {
|
export async function healthRoutes(app: FastifyInstance) {
|
||||||
app.get("/health", async () => ({
|
app.get("/health", async () => ({
|
||||||
status: "ok",
|
status: "ok",
|
||||||
|
version: backendVersion,
|
||||||
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
smtpConfigured: Boolean(process.env.SMTP_HOST),
|
||||||
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
|
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: row.blistersPerPack ?? 1,
|
blistersPerPack: row.blistersPerPack ?? 1,
|
||||||
pillsPerBlister: row.pillsPerBlister ?? 1,
|
pillsPerBlister: row.pillsPerBlister ?? 1,
|
||||||
looseTablets: row.looseTablets ?? 0,
|
looseTablets: row.looseTablets ?? 0,
|
||||||
|
stockAdjustment: row.stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
pillWeightMg: row.pillWeightMg,
|
pillWeightMg: row.pillWeightMg,
|
||||||
blisters: parseBlisters(row),
|
blisters: parseBlisters(row),
|
||||||
imageUrl: row.imageUrl,
|
imageUrl: row.imageUrl,
|
||||||
@@ -147,6 +149,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: inserted.blistersPerPack,
|
blistersPerPack: inserted.blistersPerPack,
|
||||||
pillsPerBlister: inserted.pillsPerBlister,
|
pillsPerBlister: inserted.pillsPerBlister,
|
||||||
looseTablets: inserted.looseTablets,
|
looseTablets: inserted.looseTablets,
|
||||||
|
stockAdjustment: inserted.stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
pillWeightMg: inserted.pillWeightMg,
|
pillWeightMg: inserted.pillWeightMg,
|
||||||
blisters,
|
blisters,
|
||||||
imageUrl: inserted.imageUrl,
|
imageUrl: inserted.imageUrl,
|
||||||
@@ -235,6 +239,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
blistersPerPack: result[0].blistersPerPack,
|
blistersPerPack: result[0].blistersPerPack,
|
||||||
pillsPerBlister: result[0].pillsPerBlister,
|
pillsPerBlister: result[0].pillsPerBlister,
|
||||||
looseTablets: result[0].looseTablets,
|
looseTablets: result[0].looseTablets,
|
||||||
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
pillWeightMg: result[0].pillWeightMg,
|
pillWeightMg: result[0].pillWeightMg,
|
||||||
blisters,
|
blisters,
|
||||||
imageUrl: result[0].imageUrl,
|
imageUrl: result[0].imageUrl,
|
||||||
@@ -245,6 +251,41 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stock correction endpoint - only updates stockAdjustment, preserves looseTablets
|
||||||
|
// Also sets lastStockCorrectionAt so consumed doses before this point don't count
|
||||||
|
app.patch<{ Params: { id: string }; Body: { stockAdjustment: number } }>("/medications/:id/stock-adjustment", async (req, reply) => {
|
||||||
|
const idNum = Number(req.params.id);
|
||||||
|
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||||
|
|
||||||
|
const userId = await getUserId(req, reply);
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
const [existing] = await db.select().from(medications).where(and(eq(medications.id, idNum), eq(medications.userId, userId)));
|
||||||
|
if (!existing) return reply.notFound();
|
||||||
|
|
||||||
|
const { stockAdjustment } = req.body as { stockAdjustment: number };
|
||||||
|
if (typeof stockAdjustment !== "number") return reply.badRequest("stockAdjustment must be a number");
|
||||||
|
|
||||||
|
const result = await db
|
||||||
|
.update(medications)
|
||||||
|
.set({
|
||||||
|
stockAdjustment,
|
||||||
|
lastStockCorrectionAt: new Date(), // Mark when correction was made
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(medications.id, idNum), eq(medications.userId, userId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!result.length) return reply.notFound();
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: result[0].id,
|
||||||
|
stockAdjustment: result[0].stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null,
|
||||||
|
updatedAt: result[0].updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
|
app.delete<{ Params: { id: string } }>("/medications/:id", async (req, reply) => {
|
||||||
const idNum = Number(req.params.id);
|
const idNum = Number(req.params.id);
|
||||||
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
if (Number.isNaN(idNum)) return reply.badRequest("Invalid id");
|
||||||
@@ -339,7 +380,8 @@ export async function medicationRoutes(app: FastifyInstance) {
|
|||||||
const packCount = row.packCount ?? 1;
|
const packCount = row.packCount ?? 1;
|
||||||
const blistersPerPack = row.blistersPerPack ?? 1;
|
const blistersPerPack = row.blistersPerPack ?? 1;
|
||||||
const looseTablets = row.looseTablets ?? 0;
|
const looseTablets = row.looseTablets ?? 0;
|
||||||
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets;
|
const stockAdjustment = row.stockAdjustment ?? 0;
|
||||||
|
const originalTotalPills = packCount * blistersPerPack * pillsPerBlister + looseTablets + stockAdjustment;
|
||||||
|
|
||||||
// Calculate consumption up to now (same logic as frontend)
|
// Calculate consumption up to now (same logic as frontend)
|
||||||
let consumedUntilNow = 0;
|
let consumedUntilNow = 0;
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
// Parse takenBy JSON array
|
// Parse takenBy JSON array
|
||||||
const takenByArray = parseTakenByJson(med.takenByJson);
|
const takenByArray = parseTakenByJson(med.takenByJson);
|
||||||
|
|
||||||
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
return {
|
return {
|
||||||
id: med.id,
|
id: med.id,
|
||||||
name: med.name,
|
name: med.name,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore:
|
|||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const blisters = parseBlistersFromRow(row);
|
const blisters = parseBlistersFromRow(row);
|
||||||
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets;
|
const totalPills = row.packCount * row.blistersPerPack * row.pillsPerBlister + row.looseTablets + (row.stockAdjustment ?? 0);
|
||||||
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
const { daysLeft, depletionDate } = calculateDepletionInfo({ count: totalPills, blisters }, language);
|
||||||
|
|
||||||
// Check if medication runs out within reminderDaysBefore days
|
// Check if medication runs out within reminderDaysBefore days
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ async function createSchema(client: Client) {
|
|||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
loose_tablets integer NOT NULL DEFAULT 0,
|
loose_tablets integer NOT NULL DEFAULT 0,
|
||||||
|
stock_adjustment integer NOT NULL DEFAULT 0,
|
||||||
|
last_stock_correction_at integer,
|
||||||
pill_weight_mg integer,
|
pill_weight_mg integer,
|
||||||
usage_json text NOT NULL DEFAULT '[]',
|
usage_json text NOT NULL DEFAULT '[]',
|
||||||
every_json text NOT NULL DEFAULT '[]',
|
every_json text NOT NULL DEFAULT '[]',
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ async function createSchema(client: Client) {
|
|||||||
blisters_per_pack integer NOT NULL DEFAULT 1,
|
blisters_per_pack integer NOT NULL DEFAULT 1,
|
||||||
pills_per_blister integer NOT NULL DEFAULT 1,
|
pills_per_blister integer NOT NULL DEFAULT 1,
|
||||||
loose_tablets integer NOT NULL DEFAULT 0,
|
loose_tablets integer NOT NULL DEFAULT 0,
|
||||||
|
stock_adjustment integer NOT NULL DEFAULT 0,
|
||||||
|
last_stock_correction_at integer,
|
||||||
pill_weight_mg integer,
|
pill_weight_mg integer,
|
||||||
usage_json text NOT NULL DEFAULT '[]',
|
usage_json text NOT NULL DEFAULT '[]',
|
||||||
every_json text NOT NULL DEFAULT '[]',
|
every_json text NOT NULL DEFAULT '[]',
|
||||||
|
|||||||
+2
-2
@@ -12,8 +12,8 @@ server {
|
|||||||
add_header X-XSS-Protection "1; mode=block" always;
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
# Allow larger file uploads (for medication images)
|
# Allow larger file uploads (for medication images and data import/export)
|
||||||
client_max_body_size 10M;
|
client_max_body_size 50M;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.0",
|
"version": "1.4.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+517
-81
@@ -3,6 +3,26 @@ import { Routes, Route, useNavigate, useLocation, Navigate, useParams } from "re
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { AuthProvider, useAuth, AuthPage, UserProfile } from "./components/Auth";
|
import { AuthProvider, useAuth, AuthPage, UserProfile } from "./components/Auth";
|
||||||
|
|
||||||
|
// Vite injects this at build time from package.json
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
|
const FRONTEND_VERSION = typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown';
|
||||||
|
const GITHUB_REPO = 'DanielVolz/medassist-ng';
|
||||||
|
const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
|
||||||
|
|
||||||
|
// Simple semver comparison: returns -1 if a < b, 0 if equal, 1 if a > b
|
||||||
|
function compareSemver(a: string, b: string): number {
|
||||||
|
const parseVersion = (v: string) => v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0);
|
||||||
|
const pa = parseVersion(a);
|
||||||
|
const pb = parseVersion(b);
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const va = pa[i] || 0;
|
||||||
|
const vb = pb[i] || 0;
|
||||||
|
if (va < vb) return -1;
|
||||||
|
if (va > vb) return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
type Blister = {
|
type Blister = {
|
||||||
usage: number;
|
usage: number;
|
||||||
every: number;
|
every: number;
|
||||||
@@ -18,6 +38,8 @@ type Medication = {
|
|||||||
blistersPerPack: number;
|
blistersPerPack: number;
|
||||||
pillsPerBlister: number;
|
pillsPerBlister: number;
|
||||||
looseTablets: number;
|
looseTablets: number;
|
||||||
|
stockAdjustment?: number;
|
||||||
|
lastStockCorrectionAt?: string | null; // When stock was last corrected - consumed doses before this don't count
|
||||||
pillWeightMg?: number | null;
|
pillWeightMg?: number | null;
|
||||||
blisters: Blister[];
|
blisters: Blister[];
|
||||||
imageUrl?: string | null;
|
imageUrl?: string | null;
|
||||||
@@ -27,6 +49,16 @@ type Medication = {
|
|||||||
updatedAt: string | number | null;
|
updatedAt: string | number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Helper to calculate total pills including stockAdjustment
|
||||||
|
function getMedTotal(med: Medication): number {
|
||||||
|
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get the base package size (without stockAdjustment)
|
||||||
|
function getPackageSize(med: Medication): number {
|
||||||
|
return med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
||||||
|
}
|
||||||
|
|
||||||
type PlannerRow = {
|
type PlannerRow = {
|
||||||
medicationId: number;
|
medicationId: number;
|
||||||
medicationName: string;
|
medicationName: string;
|
||||||
@@ -205,6 +237,13 @@ function AppContent() {
|
|||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { user, authState, logout } = useAuth();
|
const { user, authState, logout } = useAuth();
|
||||||
const [showProfile, setShowProfile] = useState(false);
|
const [showProfile, setShowProfile] = useState(false);
|
||||||
|
const [showAbout, setShowAbout] = useState(false);
|
||||||
|
const [backendVersion, setBackendVersion] = useState<string | null>(null);
|
||||||
|
const [updateCheckResult, setUpdateCheckResult] = useState<{
|
||||||
|
status: 'idle' | 'checking' | 'up-to-date' | 'update-available' | 'error';
|
||||||
|
latestVersion?: string;
|
||||||
|
lastChecked?: string;
|
||||||
|
}>({ status: 'idle' });
|
||||||
const [meds, setMeds] = useState<Medication[]>([]);
|
const [meds, setMeds] = useState<Medication[]>([]);
|
||||||
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
|
const [plannerRows, setPlannerRows] = useState<PlannerRow[]>([]);
|
||||||
const [plannerLoading, setPlannerLoading] = useState(false);
|
const [plannerLoading, setPlannerLoading] = useState(false);
|
||||||
@@ -366,6 +405,9 @@ function AppContent() {
|
|||||||
// Export/Import state
|
// Export/Import state
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [exportIncludeImages, setExportIncludeImages] = useState(true);
|
||||||
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
|
const [importResult, setImportResult] = useState<{medications: number, doses: number, shares: number} | null>(null);
|
||||||
// User dropdown state (for mobile click-based behavior)
|
// User dropdown state (for mobile click-based behavior)
|
||||||
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
|
const [userDropdownOpen, setUserDropdownOpen] = useState(false);
|
||||||
|
|
||||||
@@ -378,6 +420,11 @@ function AppContent() {
|
|||||||
const [refillSaving, setRefillSaving] = useState(false);
|
const [refillSaving, setRefillSaving] = useState(false);
|
||||||
const [refillHistory, setRefillHistory] = useState<RefillEntry[]>([]);
|
const [refillHistory, setRefillHistory] = useState<RefillEntry[]>([]);
|
||||||
const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
|
const [refillHistoryExpanded, setRefillHistoryExpanded] = useState(false);
|
||||||
|
// Edit stock (correction) state
|
||||||
|
const [showEditStockModal, setShowEditStockModal] = useState(false);
|
||||||
|
const [editStockFullBlisters, setEditStockFullBlisters] = useState(0);
|
||||||
|
const [editStockPartialBlisterPills, setEditStockPartialBlisterPills] = useState(0);
|
||||||
|
const [editStockSaving, setEditStockSaving] = useState(false);
|
||||||
// Collapsed days state (manually collapsed days are persisted)
|
// Collapsed days state (manually collapsed days are persisted)
|
||||||
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState<Set<string>>(new Set());
|
||||||
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
const [manuallyExpandedDays, setManuallyExpandedDays] = useState<Set<string>>(new Set());
|
||||||
@@ -538,6 +585,8 @@ function AppContent() {
|
|||||||
closeScheduleLightbox();
|
closeScheduleLightbox();
|
||||||
} else if (showImageLightbox) {
|
} else if (showImageLightbox) {
|
||||||
closeImageLightbox();
|
closeImageLightbox();
|
||||||
|
} else if (showEditStockModal) {
|
||||||
|
closeEditStockModal();
|
||||||
} else if (showRefillModal) {
|
} else if (showRefillModal) {
|
||||||
closeRefillModal();
|
closeRefillModal();
|
||||||
} else if (showEditModal) {
|
} else if (showEditModal) {
|
||||||
@@ -545,6 +594,8 @@ function AppContent() {
|
|||||||
resetForm();
|
resetForm();
|
||||||
} else if (showShareDialog) {
|
} else if (showShareDialog) {
|
||||||
closeShareDialog();
|
closeShareDialog();
|
||||||
|
} else if (showAbout) {
|
||||||
|
closeAbout();
|
||||||
} else if (showProfile) {
|
} else if (showProfile) {
|
||||||
closeProfile();
|
closeProfile();
|
||||||
} else if (selectedUser) {
|
} else if (selectedUser) {
|
||||||
@@ -556,7 +607,7 @@ function AppContent() {
|
|||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleEscape);
|
document.addEventListener("keydown", handleEscape);
|
||||||
return () => document.removeEventListener("keydown", handleEscape);
|
return () => document.removeEventListener("keydown", handleEscape);
|
||||||
}, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, showRefillModal, userDropdownOpen]);
|
}, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showEditModal, showRefillModal, showEditStockModal, userDropdownOpen]);
|
||||||
|
|
||||||
// Handle browser back button to close modals (in priority order)
|
// Handle browser back button to close modals (in priority order)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -568,6 +619,8 @@ function AppContent() {
|
|||||||
setShowImageLightbox(false);
|
setShowImageLightbox(false);
|
||||||
} else if (scheduleLightboxImage) {
|
} else if (scheduleLightboxImage) {
|
||||||
setScheduleLightboxImage(null);
|
setScheduleLightboxImage(null);
|
||||||
|
} else if (showEditStockModal) {
|
||||||
|
setShowEditStockModal(false);
|
||||||
} else if (showRefillModal) {
|
} else if (showRefillModal) {
|
||||||
setShowRefillModal(false);
|
setShowRefillModal(false);
|
||||||
} else if (showEditModal) {
|
} else if (showEditModal) {
|
||||||
@@ -575,6 +628,8 @@ function AppContent() {
|
|||||||
resetForm();
|
resetForm();
|
||||||
} else if (showShareDialog) {
|
} else if (showShareDialog) {
|
||||||
resetShareDialogState();
|
resetShareDialogState();
|
||||||
|
} else if (showAbout) {
|
||||||
|
setShowAbout(false);
|
||||||
} else if (showProfile) {
|
} else if (showProfile) {
|
||||||
setShowProfile(false);
|
setShowProfile(false);
|
||||||
} else if (selectedUser) {
|
} else if (selectedUser) {
|
||||||
@@ -585,7 +640,7 @@ function AppContent() {
|
|||||||
};
|
};
|
||||||
window.addEventListener('popstate', handlePopState);
|
window.addEventListener('popstate', handlePopState);
|
||||||
return () => window.removeEventListener('popstate', handlePopState);
|
return () => window.removeEventListener('popstate', handlePopState);
|
||||||
}, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, showRefillModal]);
|
}, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showAbout, showShareDialog, showEditModal, showRefillModal, showEditStockModal]);
|
||||||
|
|
||||||
// Close user dropdown when clicking outside
|
// Close user dropdown when clicking outside
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -636,7 +691,7 @@ function AppContent() {
|
|||||||
|
|
||||||
// Prevent background scroll when modal is open
|
// Prevent background scroll when modal is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const isModalOpen = selectedMed || selectedUser || showProfile || showShareDialog || showEditModal;
|
const isModalOpen = selectedMed || selectedUser || showProfile || showAbout || showShareDialog || showEditModal;
|
||||||
if (isModalOpen) {
|
if (isModalOpen) {
|
||||||
const scrollY = window.scrollY;
|
const scrollY = window.scrollY;
|
||||||
document.body.classList.add('modal-open');
|
document.body.classList.add('modal-open');
|
||||||
@@ -653,7 +708,7 @@ function AppContent() {
|
|||||||
document.body.classList.remove('modal-open');
|
document.body.classList.remove('modal-open');
|
||||||
document.body.style.top = '';
|
document.body.style.top = '';
|
||||||
};
|
};
|
||||||
}, [selectedMed, selectedUser, showProfile, showShareDialog, showEditModal]);
|
}, [selectedMed, selectedUser, showProfile, showAbout, showShareDialog, showEditModal]);
|
||||||
|
|
||||||
// Update selectedMed when meds change (e.g., after refill)
|
// Update selectedMed when meds change (e.g., after refill)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -906,6 +961,72 @@ function AppContent() {
|
|||||||
setRefillSaving(false);
|
setRefillSaving(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Submit a stock correction - user says how many pills they have RIGHT NOW
|
||||||
|
// The server sets lastStockCorrectionAt, so consumed doses before now won't count anymore
|
||||||
|
async function submitStockCorrection(medId: number) {
|
||||||
|
if (!selectedMed) return;
|
||||||
|
setEditStockSaving(true);
|
||||||
|
try {
|
||||||
|
// Auto-convert: handle full blister and negative partial blister
|
||||||
|
let finalFullBlisters = editStockFullBlisters;
|
||||||
|
let finalPartialPills = editStockPartialBlisterPills;
|
||||||
|
|
||||||
|
// Handle full blister: e.g. 9 pills in a 9-pill blister = +1 full blister, 0 partial
|
||||||
|
if (finalPartialPills >= selectedMed.pillsPerBlister) {
|
||||||
|
finalFullBlisters += 1;
|
||||||
|
finalPartialPills = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle negative partial: e.g. -3 with 136 full = 135 full, 6 partial (for 9-pill blister)
|
||||||
|
if (finalPartialPills < 0 && finalFullBlisters > 0) {
|
||||||
|
finalFullBlisters -= 1;
|
||||||
|
finalPartialPills = selectedMed.pillsPerBlister + finalPartialPills;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we don't go negative
|
||||||
|
if (finalPartialPills < 0) finalPartialPills = 0;
|
||||||
|
if (finalFullBlisters < 0) finalFullBlisters = 0;
|
||||||
|
|
||||||
|
// What the user says they have RIGHT NOW = the new DB total
|
||||||
|
// The server will set lastStockCorrectionAt, so all previous consumed doses are ignored
|
||||||
|
const desiredTotal = finalFullBlisters * selectedMed.pillsPerBlister + finalPartialPills;
|
||||||
|
|
||||||
|
// The "base" from DB structure (without any stockAdjustment)
|
||||||
|
const baseTotal = selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets;
|
||||||
|
|
||||||
|
// stockAdjustment = what we need to make getMedTotal() return desiredTotal
|
||||||
|
const newStockAdjustment = desiredTotal - baseTotal;
|
||||||
|
|
||||||
|
console.log('submitStockCorrection:', {
|
||||||
|
input: { fullBlisters: editStockFullBlisters, partial: editStockPartialBlisterPills },
|
||||||
|
final: { fullBlisters: finalFullBlisters, partial: finalPartialPills },
|
||||||
|
desiredTotal,
|
||||||
|
baseTotal,
|
||||||
|
newStockAdjustment
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use the PATCH endpoint - it sets stockAdjustment AND lastStockCorrectionAt
|
||||||
|
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ stockAdjustment: newStockAdjustment }),
|
||||||
|
});
|
||||||
|
console.log('PATCH response:', res.status, res.ok);
|
||||||
|
if (res.ok) {
|
||||||
|
// Close edit stock modal via history back
|
||||||
|
if (showEditStockModal) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
// Reload medications to get updated stock
|
||||||
|
loadMeds();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setEditStockSaving(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to open medication detail modal with refill history
|
// Helper to open medication detail modal with refill history
|
||||||
function openMedDetail(med: Medication) {
|
function openMedDetail(med: Medication) {
|
||||||
setSelectedMed(med);
|
setSelectedMed(med);
|
||||||
@@ -954,6 +1075,29 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openEditStockModal() {
|
||||||
|
if (!selectedMed) return;
|
||||||
|
// Get current stock from coverage (after consumption)
|
||||||
|
const medCoverage = coverage.all.find(c => c.name === selectedMed.name);
|
||||||
|
const dbTotal = getMedTotal(selectedMed);
|
||||||
|
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
||||||
|
|
||||||
|
// Simply divide into full blisters and partial
|
||||||
|
const fullBlisters = Math.floor(currentStock / selectedMed.pillsPerBlister);
|
||||||
|
const partialPills = currentStock % selectedMed.pillsPerBlister;
|
||||||
|
|
||||||
|
// Pre-fill with current values
|
||||||
|
setEditStockFullBlisters(fullBlisters);
|
||||||
|
setEditStockPartialBlisterPills(partialPills);
|
||||||
|
setShowEditStockModal(true);
|
||||||
|
window.history.pushState({ modal: 'editStock' }, '');
|
||||||
|
}
|
||||||
|
function closeEditStockModal() {
|
||||||
|
if (showEditStockModal) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openEditModal() {
|
function openEditModal() {
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
window.history.pushState({ modal: 'edit' }, '');
|
window.history.pushState({ modal: 'edit' }, '');
|
||||||
@@ -974,6 +1118,58 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openAbout() {
|
||||||
|
setShowAbout(true);
|
||||||
|
window.history.pushState({ modal: 'about' }, '');
|
||||||
|
// Fetch backend version when opening
|
||||||
|
fetch('/api/health')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => setBackendVersion(data.version || 'unknown'))
|
||||||
|
.catch(() => setBackendVersion('unknown'));
|
||||||
|
// Restore cached update check result from sessionStorage
|
||||||
|
const cached = sessionStorage.getItem('updateCheckResult');
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cached);
|
||||||
|
// Only use cache if less than 1 hour old
|
||||||
|
if (parsed.lastChecked && Date.now() - new Date(parsed.lastChecked).getTime() < 60 * 60 * 1000) {
|
||||||
|
setUpdateCheckResult(parsed);
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeAbout() {
|
||||||
|
if (showAbout) {
|
||||||
|
window.history.back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkForUpdates() {
|
||||||
|
setUpdateCheckResult({ status: 'checking' });
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`);
|
||||||
|
if (!res.ok) throw new Error('Failed to fetch');
|
||||||
|
const data = await res.json();
|
||||||
|
const latestVersion = data.tag_name?.replace(/^v/, '') || data.name?.replace(/^v/, '');
|
||||||
|
const lastChecked = new Date().toISOString();
|
||||||
|
|
||||||
|
// Compare with current version (use frontend version as reference)
|
||||||
|
const currentVersion = FRONTEND_VERSION;
|
||||||
|
const needsUpdate = compareSemver(currentVersion, latestVersion) < 0;
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
status: needsUpdate ? 'update-available' as const : 'up-to-date' as const,
|
||||||
|
latestVersion,
|
||||||
|
lastChecked,
|
||||||
|
};
|
||||||
|
setUpdateCheckResult(result);
|
||||||
|
// Cache result in sessionStorage
|
||||||
|
sessionStorage.setItem('updateCheckResult', JSON.stringify(result));
|
||||||
|
} catch {
|
||||||
|
setUpdateCheckResult({ status: 'error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openUserFilter(person: string) {
|
function openUserFilter(person: string) {
|
||||||
setSelectedUser(person);
|
setSelectedUser(person);
|
||||||
window.history.pushState({ modal: 'userFilter', person }, '');
|
window.history.pushState({ modal: 'userFilter', person }, '');
|
||||||
@@ -1087,10 +1283,10 @@ function AppContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Export data to JSON file
|
// Export data to JSON file
|
||||||
async function handleExport() {
|
async function handleExport(includeImages: boolean = true) {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/export?includeSensitive=true', {
|
const res = await fetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`, {
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Export failed");
|
if (!res.ok) throw new Error("Export failed");
|
||||||
@@ -1151,18 +1347,28 @@ function AppContent() {
|
|||||||
body: JSON.stringify(pendingImportData),
|
body: JSON.stringify(pendingImportData),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
// Get the response text first to handle non-JSON responses
|
||||||
const err = await res.json();
|
const text = await res.text();
|
||||||
alert(t('exportImport.importError') + ": " + (err.error || "Unknown error"));
|
let data;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
console.error("Import response parse error:", text);
|
||||||
|
alert(t('exportImport.importError') + ": Server returned invalid response");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await res.json();
|
if (!res.ok) {
|
||||||
alert(t('exportImport.importSuccess') + "\n" + t('exportImport.importSuccessDetails', {
|
alert(t('exportImport.importError') + ": " + (data.error || `HTTP ${res.status}`));
|
||||||
medications: result.imported.medications,
|
return;
|
||||||
doses: result.imported.doseHistory,
|
}
|
||||||
shares: result.imported.shareLinks,
|
|
||||||
}));
|
// Show success message in UI instead of browser alert
|
||||||
|
setImportResult({
|
||||||
|
medications: data.imported?.medications || 0,
|
||||||
|
doses: data.imported?.doseHistory || 0,
|
||||||
|
shares: data.imported?.shareLinks || 0,
|
||||||
|
});
|
||||||
|
|
||||||
// Reload all data
|
// Reload all data
|
||||||
loadMeds();
|
loadMeds();
|
||||||
@@ -1567,6 +1773,10 @@ function AppContent() {
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
{t('nav.settings', 'Settings')}
|
{t('nav.settings', 'Settings')}
|
||||||
</button>
|
</button>
|
||||||
|
<button className="dropdown-item" onClick={() => { openAbout(); setUserDropdownOpen(false); }}>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>
|
||||||
|
{t('about.title', 'About')}
|
||||||
|
</button>
|
||||||
<button className="dropdown-item danger" onClick={() => { logout(); setUserDropdownOpen(false); }}>
|
<button className="dropdown-item danger" onClick={() => { logout(); setUserDropdownOpen(false); }}>
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||||
{t('auth.signOut', 'Sign Out')}
|
{t('auth.signOut', 'Sign Out')}
|
||||||
@@ -1588,6 +1798,91 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* About Modal */}
|
||||||
|
{showAbout && (
|
||||||
|
<div className="modal-overlay" onClick={() => closeAbout()}>
|
||||||
|
<div className="modal-content about-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button className="modal-close" onClick={() => closeAbout()}>×</button>
|
||||||
|
<div className="about-header">
|
||||||
|
<div className="about-logo">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
|
<path d="M19.5 12c0 4.14-3.36 7.5-7.5 7.5S4.5 16.14 4.5 12 7.86 4.5 12 4.5s7.5 3.36 7.5 7.5z"/>
|
||||||
|
<path d="M12 8v4l2.5 2.5"/>
|
||||||
|
<path d="M9 2h6M12 2v2"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2>{t('about.appName', 'MedAssist')}</h2>
|
||||||
|
<p className="about-tagline">{t('about.description', 'Personal medication tracking and reminder app')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="about-versions">
|
||||||
|
<div className="about-version-row">
|
||||||
|
<span className="about-version-label">{t('about.frontendVersion', 'Frontend')}</span>
|
||||||
|
<span className="about-version-value">{FRONTEND_VERSION}</span>
|
||||||
|
</div>
|
||||||
|
<div className="about-version-row">
|
||||||
|
<span className="about-version-label">{t('about.backendVersion', 'Backend')}</span>
|
||||||
|
<span className="about-version-value">{backendVersion || '...'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="about-update-section">
|
||||||
|
<button className="about-update-btn" onClick={checkForUpdates} disabled={updateCheckResult?.status === 'checking'}>
|
||||||
|
{updateCheckResult?.status === 'checking' ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-small"></span>
|
||||||
|
{t('about.checking', 'Checking...')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
|
||||||
|
<path d="M3 3v5h5"/>
|
||||||
|
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/>
|
||||||
|
<path d="M16 16h5v5"/>
|
||||||
|
</svg>
|
||||||
|
{t('about.checkForUpdates', 'Check for Updates')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{updateCheckResult && updateCheckResult.status !== 'checking' && (
|
||||||
|
<div className={`about-update-result ${updateCheckResult.status}`}>
|
||||||
|
{updateCheckResult.status === 'up-to-date' && (
|
||||||
|
<span className="update-status-text">✓ {t('about.upToDate', 'You are up to date!')}</span>
|
||||||
|
)}
|
||||||
|
{updateCheckResult.status === 'update-available' && (
|
||||||
|
<span className="update-status-text">
|
||||||
|
⬆ {t('about.updateAvailable', 'Update available')}: <strong>v{updateCheckResult.latestVersion}</strong>
|
||||||
|
<a href={`${GITHUB_URL}/releases/latest`} target="_blank" rel="noopener noreferrer" className="update-download-link">
|
||||||
|
{t('about.downloadUpdate', 'Download')}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{updateCheckResult.status === 'error' && (
|
||||||
|
<span className="update-status-text">⚠ {t('about.checkFailed', 'Could not check for updates')}</span>
|
||||||
|
)}
|
||||||
|
{updateCheckResult.lastChecked && (
|
||||||
|
<span className="update-last-checked">
|
||||||
|
{t('about.lastChecked', 'Last checked')}: {new Date(updateCheckResult.lastChecked).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="about-links">
|
||||||
|
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className="about-link">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||||
|
</svg>
|
||||||
|
{t('about.viewOnGitHub', 'View on GitHub')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className="about-footer">
|
||||||
|
<p className="about-copyright">{t('about.copyright', '© {{year}} Daniel Volz', { year: new Date().getFullYear() })}</p>
|
||||||
|
<p className="about-license">{t('about.license', 'GPL-3.0 License')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||||
<Route path="/dashboard" element={
|
<Route path="/dashboard" element={
|
||||||
@@ -1650,7 +1945,7 @@ function AppContent() {
|
|||||||
Math.round(row.medsLeft),
|
Math.round(row.medsLeft),
|
||||||
med?.pillsPerBlister ?? 1,
|
med?.pillsPerBlister ?? 1,
|
||||||
med?.looseTablets ?? 0,
|
med?.looseTablets ?? 0,
|
||||||
med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft)
|
med ? getMedTotal(med) : Math.round(row.medsLeft)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||||
@@ -1719,7 +2014,7 @@ function AppContent() {
|
|||||||
Math.round(row.medsLeft),
|
Math.round(row.medsLeft),
|
||||||
med?.pillsPerBlister ?? 1,
|
med?.pillsPerBlister ?? 1,
|
||||||
med?.looseTablets ?? 0,
|
med?.looseTablets ?? 0,
|
||||||
med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft)
|
med ? getMedTotal(med) : Math.round(row.medsLeft)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
<div key={row.name} className="table-row clickable" onClick={() => med && openMedDetail(med)}>
|
||||||
@@ -2088,7 +2383,7 @@ function AppContent() {
|
|||||||
<span>{t('medications.details.pillsPerBlister')}: <strong>{med.pillsPerBlister}</strong></span>
|
<span>{t('medications.details.pillsPerBlister')}: <strong>{med.pillsPerBlister}</strong></span>
|
||||||
<span>{t('medications.details.loose')}: <strong>{med.looseTablets}</strong></span>
|
<span>{t('medications.details.loose')}: <strong>{med.looseTablets}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-total">{t('medications.details.total')}: {med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets} {t('common.pills')}</div>
|
<div className="med-total">{t('medications.details.total')}: {getPackageSize(med)} {t('common.pills')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="med-actions">
|
<div className="med-actions">
|
||||||
<button className="info" onClick={() => startEdit(med)}>{t('common.edit')}</button>
|
<button className="info" onClick={() => startEdit(med)}>{t('common.edit')}</button>
|
||||||
@@ -2110,11 +2405,6 @@ function AppContent() {
|
|||||||
<article className="card form desktop-only">
|
<article className="card form desktop-only">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
|
<h2>{editingId ? t('form.editEntry') : t('form.newEntry')}</h2>
|
||||||
{editingId && (
|
|
||||||
<button type="button" className="btn secondary small" onClick={resetForm}>
|
|
||||||
+ {t('form.newEntry')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<form className="form-grid" onSubmit={saveMedication}>
|
<form className="form-grid" onSubmit={saveMedication}>
|
||||||
<label className={fieldErrors.name ? 'has-error' : ''}>
|
<label className={fieldErrors.name ? 'has-error' : ''}>
|
||||||
@@ -2842,6 +3132,27 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="setting-section">
|
<div className="setting-section">
|
||||||
<div className="setting-group">
|
<div className="setting-group">
|
||||||
|
{/* Import Success Message */}
|
||||||
|
{importResult && (
|
||||||
|
<div className="success-banner" style={{marginBottom: '16px', padding: '12px 16px', borderRadius: '8px', backgroundColor: 'var(--success-bg)', border: '1px solid var(--success)', color: 'var(--text-primary)'}}>
|
||||||
|
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start'}}>
|
||||||
|
<div>
|
||||||
|
<strong style={{display: 'block', marginBottom: '4px', color: 'var(--success)'}}>✓ {t('exportImport.importSuccess')}</strong>
|
||||||
|
<span style={{fontSize: '0.9em'}}>{t('exportImport.importSuccessDetails', {
|
||||||
|
medications: importResult.medications,
|
||||||
|
doses: importResult.doses,
|
||||||
|
shares: importResult.shares
|
||||||
|
})}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setImportResult(null)}
|
||||||
|
style={{background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.2em', padding: '0', lineHeight: '1', color: 'inherit', opacity: 0.7}}
|
||||||
|
aria-label="Close"
|
||||||
|
>×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Export */}
|
{/* Export */}
|
||||||
<div className="action-card">
|
<div className="action-card">
|
||||||
<div className="action-card-content">
|
<div className="action-card-content">
|
||||||
@@ -2851,7 +3162,7 @@ function AppContent() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={handleExport}
|
onClick={() => setShowExportModal(true)}
|
||||||
disabled={exporting}
|
disabled={exporting}
|
||||||
>
|
>
|
||||||
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
|
{exporting ? t('exportImport.exporting') : t('exportImport.export')}
|
||||||
@@ -2864,16 +3175,22 @@ function AppContent() {
|
|||||||
<span className="action-card-title">{t('exportImport.importTitle')}</span>
|
<span className="action-card-title">{t('exportImport.importTitle')}</span>
|
||||||
<span className="action-card-desc">{t('exportImport.importDesc')}</span>
|
<span className="action-card-desc">{t('exportImport.importDesc')}</span>
|
||||||
</div>
|
</div>
|
||||||
<label className="btn secondary">
|
<input
|
||||||
|
type="file"
|
||||||
|
id="import-file-input"
|
||||||
|
accept=".json,application/json"
|
||||||
|
onChange={handleImportFileSelect}
|
||||||
|
disabled={importing}
|
||||||
|
style={{display: 'none'}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="secondary"
|
||||||
|
onClick={() => document.getElementById('import-file-input')?.click()}
|
||||||
|
disabled={importing}
|
||||||
|
>
|
||||||
{importing ? t('exportImport.importing') : t('exportImport.import')}
|
{importing ? t('exportImport.importing') : t('exportImport.import')}
|
||||||
<input
|
</button>
|
||||||
type="file"
|
|
||||||
accept=".json,application/json"
|
|
||||||
onChange={handleImportFileSelect}
|
|
||||||
disabled={importing}
|
|
||||||
style={{display: 'none'}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2919,6 +3236,57 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Export Options Modal */}
|
||||||
|
{showExportModal && (
|
||||||
|
<div className="modal-overlay" onClick={() => setShowExportModal(false)}>
|
||||||
|
<div className="modal-content" onClick={(e) => e.stopPropagation()} style={{maxWidth: "450px"}}>
|
||||||
|
<button className="modal-close" onClick={() => setShowExportModal(false)}>×</button>
|
||||||
|
<h2 style={{marginBottom: "16px", paddingRight: "2rem"}}>{t('exportImport.exportOptions')}</h2>
|
||||||
|
<div style={{display: 'flex', flexDirection: 'column', gap: '12px'}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="action-card"
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportModal(false);
|
||||||
|
handleExport(true);
|
||||||
|
}}
|
||||||
|
disabled={exporting}
|
||||||
|
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
|
||||||
|
>
|
||||||
|
<div className="action-card-content" style={{flex: 1}}>
|
||||||
|
<span className="action-card-title">{t('exportImport.exportWithImages')}</span>
|
||||||
|
<span className="action-card-desc">{t('exportImport.exportWithImagesDesc')}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="action-card"
|
||||||
|
onClick={() => {
|
||||||
|
setShowExportModal(false);
|
||||||
|
handleExport(false);
|
||||||
|
}}
|
||||||
|
disabled={exporting}
|
||||||
|
style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}}
|
||||||
|
>
|
||||||
|
<div className="action-card-content" style={{flex: 1}}>
|
||||||
|
<span className="action-card-title">{t('exportImport.exportDataOnly')}</span>
|
||||||
|
<span className="action-card-desc">{t('exportImport.exportDataOnlyDesc')}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer" style={{padding: "1rem 0 0 0", borderTop: "none", justifyContent: "flex-end"}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => setShowExportModal(false)}
|
||||||
|
>
|
||||||
|
{t('exportImport.cancelButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
@@ -3136,15 +3504,15 @@ function AppContent() {
|
|||||||
<h3>{t('modal.stockInfo')}</h3>
|
<h3>{t('modal.stockInfo')}</h3>
|
||||||
{(() => {
|
{(() => {
|
||||||
const medCoverage = coverage.all.find(c => c.name === selectedMed.name);
|
const medCoverage = coverage.all.find(c => c.name === selectedMed.name);
|
||||||
const totalStock = selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets;
|
const packageSize = getPackageSize(selectedMed);
|
||||||
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : totalStock;
|
const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : getMedTotal(selectedMed);
|
||||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||||
const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
|
const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text";
|
||||||
const stock = getBlisterStock(
|
const stock = getBlisterStock(
|
||||||
currentStock,
|
currentStock,
|
||||||
selectedMed.pillsPerBlister,
|
selectedMed.pillsPerBlister,
|
||||||
selectedMed.looseTablets,
|
selectedMed.looseTablets,
|
||||||
totalStock
|
packageSize
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div className="med-detail-grid">
|
<div className="med-detail-grid">
|
||||||
@@ -3158,7 +3526,7 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="med-detail-item full-width">
|
<div className="med-detail-item full-width">
|
||||||
<span className="med-detail-label">{t('modal.currentStock')}</span>
|
<span className="med-detail-label">{t('modal.currentStock')}</span>
|
||||||
<span className={`med-detail-value ${textClass}`}>{currentStock} / {totalStock}</span>
|
<span className={`med-detail-value ${textClass}`}>{currentStock} / {packageSize}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -3285,7 +3653,7 @@ function AppContent() {
|
|||||||
<button className="success" onClick={openRefillModal}>
|
<button className="success" onClick={openRefillModal}>
|
||||||
{t('refill.button')}
|
{t('refill.button')}
|
||||||
</button>
|
</button>
|
||||||
<button className="info" onClick={() => { closeMedDetail(); navigate("/medications"); startEdit(selectedMed); }}>
|
<button className="info" onClick={openEditStockModal}>
|
||||||
{t('common.edit')}
|
{t('common.edit')}
|
||||||
</button>
|
</button>
|
||||||
{selectedMed.blisters.length > 0 && (
|
{selectedMed.blisters.length > 0 && (
|
||||||
@@ -3359,6 +3727,88 @@ function AppContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Edit Stock Modal */}
|
||||||
|
{showEditStockModal && (
|
||||||
|
<div className="modal-overlay" onClick={(e) => { e.stopPropagation(); closeEditStockModal(); }}>
|
||||||
|
<div className="modal-content edit-stock-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<button className="modal-close" onClick={closeEditStockModal}>×</button>
|
||||||
|
<h2>{t('editStock.title')}</h2>
|
||||||
|
<p className="edit-stock-med-name">{selectedMed.name}</p>
|
||||||
|
<p className="edit-stock-hint">{t('editStock.hint')}</p>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
// Get current stock from coverage
|
||||||
|
const medCoverage = coverage.all.find(c => c.name === selectedMed.name);
|
||||||
|
const dbTotal = getMedTotal(selectedMed);
|
||||||
|
const currentTotal = medCoverage ? Math.round(medCoverage.medsLeft) : dbTotal;
|
||||||
|
|
||||||
|
// New total from user input
|
||||||
|
const newTotal = editStockFullBlisters * selectedMed.pillsPerBlister + editStockPartialBlisterPills;
|
||||||
|
const difference = newTotal - currentTotal;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="edit-stock-form">
|
||||||
|
<label>
|
||||||
|
{t('editStock.fullBlisters')} {t('editStock.pillsPerBlister', { count: selectedMed.pillsPerBlister })}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={editStockFullBlisters}
|
||||||
|
onChange={(e) => setEditStockFullBlisters(parseInt(e.target.value) || 0)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{t('editStock.partialBlisterPills')}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0}
|
||||||
|
max={selectedMed.pillsPerBlister}
|
||||||
|
value={editStockPartialBlisterPills}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseInt(e.target.value) || 0;
|
||||||
|
const min = editStockFullBlisters > 0 ? -(selectedMed.pillsPerBlister - 1) : 0;
|
||||||
|
const max = selectedMed.pillsPerBlister;
|
||||||
|
setEditStockPartialBlisterPills(Math.max(min, Math.min(val, max)));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="edit-stock-summary">
|
||||||
|
<div className="summary-row">
|
||||||
|
<span>{t('editStock.currentTotal')}:</span>
|
||||||
|
<span>{currentTotal} {t('common.pills')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="summary-row">
|
||||||
|
<span>{t('editStock.newTotal')}:</span>
|
||||||
|
<span>{newTotal} {t('common.pills')}</span>
|
||||||
|
</div>
|
||||||
|
<div className={`summary-row difference ${difference > 0 ? 'positive' : difference < 0 ? 'negative' : ''}`}>
|
||||||
|
<span>{t('editStock.difference')}:</span>
|
||||||
|
<span>{difference > 0 ? '+' : ''}{difference} {t('common.pills')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
<div className="modal-footer">
|
||||||
|
<button className="ghost" onClick={closeEditStockModal}>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="info"
|
||||||
|
onClick={() => submitStockCorrection(selectedMed.id)}
|
||||||
|
disabled={editStockSaving}
|
||||||
|
>
|
||||||
|
{editStockSaving ? t('editStock.saving') : t('editStock.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -3377,7 +3827,7 @@ function AppContent() {
|
|||||||
{meds.filter(m => (m.takenBy || []).includes(selectedUser)).map((med) => {
|
{meds.filter(m => (m.takenBy || []).includes(selectedUser)).map((med) => {
|
||||||
const medCoverage = coverage.all.find(c => c.name === med.name);
|
const medCoverage = coverage.all.find(c => c.name === med.name);
|
||||||
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null;
|
||||||
const totalPills = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
const totalPills = getMedTotal(med);
|
||||||
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(totalPills);
|
const currentStock = medCoverage ? formatNumber(medCoverage.medsLeft) : formatNumber(totalPills);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -3881,35 +4331,23 @@ function formatNumber(value: number | null) {
|
|||||||
return value.toFixed(1);
|
return value.toFixed(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate blister stock with realistic consumption order:
|
// Calculate blister stock - simply divides current pills into full blisters and partial
|
||||||
// Loose pills are consumed FIRST, then blisters are opened
|
// No separate "loose pills" tracking - everything is displayed as blisters
|
||||||
function getBlisterStock(
|
function getBlisterStock(
|
||||||
currentPills: number,
|
currentPills: number,
|
||||||
pillsPerBlister: number,
|
pillsPerBlister: number,
|
||||||
originalLooseTablets: number,
|
_originalLooseTablets: number,
|
||||||
originalTotalPills: number
|
_originalTotalPills: number
|
||||||
): { fullBlisters: number; openBlisterPills: number; loosePills: number } {
|
): { fullBlisters: number; openBlisterPills: number; loosePills: number } {
|
||||||
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
|
if (pillsPerBlister <= 0 || pillsPerBlister === 1) {
|
||||||
return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills };
|
return { fullBlisters: 0, openBlisterPills: 0, loosePills: currentPills };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate how many pills have been consumed
|
// Simply divide current pills into full blisters and partial
|
||||||
const consumed = originalTotalPills - currentPills;
|
const fullBlisters = Math.floor(currentPills / pillsPerBlister);
|
||||||
|
const openBlisterPills = currentPills % pillsPerBlister;
|
||||||
|
|
||||||
// Loose pills are consumed first
|
return { fullBlisters, openBlisterPills, loosePills: 0 };
|
||||||
const looseConsumed = Math.min(consumed, originalLooseTablets);
|
|
||||||
const loosePillsRemaining = originalLooseTablets - looseConsumed;
|
|
||||||
|
|
||||||
// Remaining consumption comes from blisters
|
|
||||||
const blisterPillsConsumed = consumed - looseConsumed;
|
|
||||||
const originalBlisterPills = originalTotalPills - originalLooseTablets;
|
|
||||||
const blisterPillsRemaining = originalBlisterPills - blisterPillsConsumed;
|
|
||||||
|
|
||||||
// Calculate full blisters and open blister
|
|
||||||
const fullBlisters = Math.floor(blisterPillsRemaining / pillsPerBlister);
|
|
||||||
const openBlisterPills = blisterPillsRemaining % pillsPerBlister;
|
|
||||||
|
|
||||||
return { fullBlisters, openBlisterPills, loosePills: loosePillsRemaining };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format full blisters column
|
// Format full blisters column
|
||||||
@@ -3918,26 +4356,17 @@ function formatFullBlisters(fullBlisters: number, t: (key: string) => string): s
|
|||||||
return `${fullBlisters} ${fullBlisters === 1 ? t('common.blister') : t('common.blisters')}`;
|
return `${fullBlisters} ${fullBlisters === 1 ? t('common.blister') : t('common.blisters')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format open blister + loose pills column
|
// Format open blister column (no separate loose pills display)
|
||||||
function formatOpenBlisterAndLoose(
|
function formatOpenBlisterAndLoose(
|
||||||
openBlisterPills: number,
|
openBlisterPills: number,
|
||||||
loosePills: number,
|
_loosePills: number,
|
||||||
pillsPerBlister: number,
|
pillsPerBlister: number,
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
): string {
|
): string {
|
||||||
// Format open blister part
|
if (openBlisterPills > 0) {
|
||||||
const openBlisterText = openBlisterPills > 0
|
return `${openBlisterPills} ${t('common.of')} ${pillsPerBlister} ${t('common.pills')}`;
|
||||||
? `${openBlisterPills} ${t('common.of')} ${pillsPerBlister} ${t('common.pills')}`
|
|
||||||
: t('common.none');
|
|
||||||
|
|
||||||
// Format loose pills part (if any)
|
|
||||||
if (loosePills > 0) {
|
|
||||||
return `${openBlisterText} + ${loosePills} ${t('common.loose')}`;
|
|
||||||
}
|
}
|
||||||
|
return "—";
|
||||||
// No loose pills
|
|
||||||
if (openBlisterPills === 0) return "—";
|
|
||||||
return openBlisterText;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExpiryClass(expiryDate: string | null | undefined, expiryWarningDays: number = 30): string {
|
function getExpiryClass(expiryDate: string | null | undefined, expiryWarningDays: number = 30): string {
|
||||||
@@ -3970,14 +4399,19 @@ function calculateCoverage(
|
|||||||
|
|
||||||
let consumed = 0;
|
let consumed = 0;
|
||||||
|
|
||||||
|
// Get the cutoff time - only count doses taken AFTER the last stock correction
|
||||||
|
const stockCorrectionCutoff = m.lastStockCorrectionAt ? new Date(m.lastStockCorrectionAt).getTime() : 0;
|
||||||
|
|
||||||
if (stockCalculationMode === "automatic") {
|
if (stockCalculationMode === "automatic") {
|
||||||
// Automatic mode: calculate consumed based on schedule since start date
|
// Automatic mode: calculate consumed based on schedule since start date (or last correction)
|
||||||
// Multiply by personCount since each person takes the medication
|
// Multiply by personCount since each person takes the medication
|
||||||
m.blisters.forEach((s) => {
|
m.blisters.forEach((s) => {
|
||||||
const start = new Date(s.start).getTime();
|
const blisterStart = new Date(s.start).getTime();
|
||||||
if (Number.isNaN(start) || start > now) return;
|
// Use the LATER of blister start or stock correction time
|
||||||
|
const effectiveStart = Math.max(blisterStart, stockCorrectionCutoff);
|
||||||
|
if (Number.isNaN(effectiveStart) || effectiveStart > now) return;
|
||||||
const period = Math.max(1, s.every) * MS_PER_DAY;
|
const period = Math.max(1, s.every) * MS_PER_DAY;
|
||||||
const occurrences = Math.floor((now - start) / period) + 1; // include today if started
|
const occurrences = Math.floor((now - effectiveStart) / period) + 1; // include today if started
|
||||||
consumed += occurrences * s.usage * personCount;
|
consumed += occurrences * s.usage * personCount;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -3990,9 +4424,11 @@ function calculateCoverage(
|
|||||||
const blisterIdx = parseInt(parts[1], 10);
|
const blisterIdx = parseInt(parts[1], 10);
|
||||||
const doseTimestamp = parseInt(parts[2], 10);
|
const doseTimestamp = parseInt(parts[2], 10);
|
||||||
if (medId === m.id && m.blisters[blisterIdx]) {
|
if (medId === m.id && m.blisters[blisterIdx]) {
|
||||||
// Only count doses that are on or after the blister's start date
|
// Only count doses that are:
|
||||||
|
// 1. On or after the blister's start date
|
||||||
|
// 2. AFTER the last stock correction (if any)
|
||||||
const blisterStart = new Date(m.blisters[blisterIdx].start).getTime();
|
const blisterStart = new Date(m.blisters[blisterIdx].start).getTime();
|
||||||
if (!Number.isNaN(blisterStart) && doseTimestamp >= blisterStart) {
|
if (!Number.isNaN(blisterStart) && doseTimestamp >= blisterStart && doseTimestamp > stockCorrectionCutoff) {
|
||||||
// Each taken dose (regardless of person) consumes the usage amount
|
// Each taken dose (regardless of person) consumes the usage amount
|
||||||
consumed += m.blisters[blisterIdx].usage;
|
consumed += m.blisters[blisterIdx].usage;
|
||||||
}
|
}
|
||||||
@@ -4001,7 +4437,7 @@ function calculateCoverage(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalPills = m.packCount * m.blistersPerPack * m.pillsPerBlister + m.looseTablets;
|
const totalPills = getMedTotal(m);
|
||||||
const medsLeft = Math.max(0, totalPills - consumed);
|
const medsLeft = Math.max(0, totalPills - consumed);
|
||||||
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
|
const rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null;
|
||||||
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null; // conservative: round down
|
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null; // conservative: round down
|
||||||
@@ -4567,7 +5003,7 @@ function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const med of data.medications) {
|
for (const med of data.medications) {
|
||||||
const totalCount = med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets;
|
const totalCount = getMedTotal(med);
|
||||||
const taken = takenByMed[med.name] || 0;
|
const taken = takenByMed[med.name] || 0;
|
||||||
const currentCount = Math.max(0, totalCount - taken);
|
const currentCount = Math.max(0, totalCount - taken);
|
||||||
// Calculate daily usage from blisters, multiplied by number of people
|
// Calculate daily usage from blisters, multiplied by number of people
|
||||||
|
|||||||
@@ -330,7 +330,8 @@
|
|||||||
"fullBlister": "voller Blister",
|
"fullBlister": "voller Blister",
|
||||||
"fullBlisters": "volle Blister",
|
"fullBlisters": "volle Blister",
|
||||||
"inBlister": "in 1 Blister",
|
"inBlister": "in 1 Blister",
|
||||||
"total": "gesamt"
|
"total": "gesamt",
|
||||||
|
"max": "max"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"button": "Teilen",
|
"button": "Teilen",
|
||||||
@@ -372,6 +373,13 @@
|
|||||||
"selectFile": "Datei auswählen",
|
"selectFile": "Datei auswählen",
|
||||||
"includeSensitive": "Sensible Daten einschließen (Benachrichtigungs-URLs)",
|
"includeSensitive": "Sensible Daten einschließen (Benachrichtigungs-URLs)",
|
||||||
"sensitiveWarning": "Benachrichtigungs-URLs können Passwörter enthalten und werden im Klartext gespeichert.",
|
"sensitiveWarning": "Benachrichtigungs-URLs können Passwörter enthalten und werden im Klartext gespeichert.",
|
||||||
|
"includeImages": "Medikamentenbilder einschließen",
|
||||||
|
"includeImagesHint": "Bilder vergrößern die Datei erheblich. Deaktivieren für kleinere Exports (~50 KB statt mehrere MB).",
|
||||||
|
"exportOptions": "Export-Optionen",
|
||||||
|
"exportWithImages": "Mit Bildern",
|
||||||
|
"exportWithImagesDesc": "Vollständiges Backup mit allen Medikamentenbildern. Größere Datei.",
|
||||||
|
"exportDataOnly": "Nur Daten",
|
||||||
|
"exportDataOnlyDesc": "Kompaktes Backup ohne Bilder. Viel kleinere Datei (~50 KB).",
|
||||||
"confirmImport": "Alle Daten ersetzen?",
|
"confirmImport": "Alle Daten ersetzen?",
|
||||||
"confirmImportMessage": "Dies löscht dauerhaft alle deine aktuellen Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links und ersetzt sie durch die importierten Daten.",
|
"confirmImportMessage": "Dies löscht dauerhaft alle deine aktuellen Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links und ersetzt sie durch die importierten Daten.",
|
||||||
"confirmImportWarning": "Diese Aktion kann nicht rückgängig gemacht werden!",
|
"confirmImportWarning": "Diese Aktion kann nicht rückgängig gemacht werden!",
|
||||||
@@ -399,5 +407,39 @@
|
|||||||
"pillsAdded": "{{count}} Tablette",
|
"pillsAdded": "{{count}} Tablette",
|
||||||
"pillsAdded_other": "{{count}} Tabletten",
|
"pillsAdded_other": "{{count}} Tabletten",
|
||||||
"button": "Nachfüllen"
|
"button": "Nachfüllen"
|
||||||
|
},
|
||||||
|
"editStock": {
|
||||||
|
"title": "Bestand korrigieren",
|
||||||
|
"hint": "Dies ist für die Korrektur von Bestandsabweichungen. Für normale Bestandsänderungen nutze 'Nachfüllen'.",
|
||||||
|
"fullBlisters": "Volle Blister",
|
||||||
|
"partialBlisterPills": "Angebrochener Blister",
|
||||||
|
"pillsPerBlister": "(je {{count}} Tabletten)",
|
||||||
|
"currentTotal": "Aktueller Bestand",
|
||||||
|
"newTotal": "Neuer Bestand",
|
||||||
|
"difference": "Differenz",
|
||||||
|
"save": "Korrektur speichern",
|
||||||
|
"saving": "Speichern...",
|
||||||
|
"success": "Bestand erfolgreich korrigiert"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "Über",
|
||||||
|
"appName": "MedAssist-ng",
|
||||||
|
"description": "Open-Source Medikamentenverwaltung und Planungsanwendung für selbst gehostete Umgebungen.",
|
||||||
|
"version": "Version",
|
||||||
|
"frontend": "Frontend",
|
||||||
|
"backend": "Backend",
|
||||||
|
"checkForUpdates": "Nach Updates suchen",
|
||||||
|
"checking": "Prüfe...",
|
||||||
|
"upToDate": "Du bist auf dem neuesten Stand!",
|
||||||
|
"updateAvailable": "Update verfügbar",
|
||||||
|
"viewOnGitHub": "Auf GitHub ansehen",
|
||||||
|
"downloadUpdate": "Update herunterladen",
|
||||||
|
"checkFailed": "Update-Prüfung fehlgeschlagen",
|
||||||
|
"lastChecked": "Zuletzt geprüft",
|
||||||
|
"github": "GitHub",
|
||||||
|
"license": "MIT-Lizenz",
|
||||||
|
"copyright": "© {{year}} Daniel Volz",
|
||||||
|
"madeWith": "Mit ❤️ erstellt für besseres Gesundheitsmanagement",
|
||||||
|
"techStack": "Entwickelt mit React, Fastify & SQLite"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,7 +332,8 @@
|
|||||||
"fullBlister": "full blister",
|
"fullBlister": "full blister",
|
||||||
"fullBlisters": "full blisters",
|
"fullBlisters": "full blisters",
|
||||||
"inBlister": "in 1 blister",
|
"inBlister": "in 1 blister",
|
||||||
"total": "total"
|
"total": "total",
|
||||||
|
"max": "max"
|
||||||
},
|
},
|
||||||
"share": {
|
"share": {
|
||||||
"button": "Share",
|
"button": "Share",
|
||||||
@@ -374,6 +375,13 @@
|
|||||||
"selectFile": "Select File",
|
"selectFile": "Select File",
|
||||||
"includeSensitive": "Include sensitive data (notification URLs)",
|
"includeSensitive": "Include sensitive data (notification URLs)",
|
||||||
"sensitiveWarning": "Notification URLs may contain passwords and will be stored in plain text.",
|
"sensitiveWarning": "Notification URLs may contain passwords and will be stored in plain text.",
|
||||||
|
"includeImages": "Include medication images",
|
||||||
|
"includeImagesHint": "Images significantly increase file size. Uncheck for smaller exports (~50 KB instead of several MB).",
|
||||||
|
"exportOptions": "Export Options",
|
||||||
|
"exportWithImages": "With Images",
|
||||||
|
"exportWithImagesDesc": "Full backup including all medication images. Larger file size.",
|
||||||
|
"exportDataOnly": "Data Only",
|
||||||
|
"exportDataOnlyDesc": "Compact backup without images. Much smaller file size (~50 KB).",
|
||||||
"confirmImport": "Replace All Data?",
|
"confirmImport": "Replace All Data?",
|
||||||
"confirmImportMessage": "This will permanently delete all your current medications, dose history, settings, and share links, then replace them with the imported data.",
|
"confirmImportMessage": "This will permanently delete all your current medications, dose history, settings, and share links, then replace them with the imported data.",
|
||||||
"confirmImportWarning": "This action cannot be undone!",
|
"confirmImportWarning": "This action cannot be undone!",
|
||||||
@@ -401,5 +409,39 @@
|
|||||||
"pillsAdded": "{{count}} pill",
|
"pillsAdded": "{{count}} pill",
|
||||||
"pillsAdded_other": "{{count}} pills",
|
"pillsAdded_other": "{{count}} pills",
|
||||||
"button": "Refill"
|
"button": "Refill"
|
||||||
|
},
|
||||||
|
"editStock": {
|
||||||
|
"title": "Correct Stock",
|
||||||
|
"hint": "This is for correcting stock discrepancies. For regular stock changes, use 'Refill'.",
|
||||||
|
"fullBlisters": "Full blisters",
|
||||||
|
"partialBlisterPills": "Partial blister",
|
||||||
|
"pillsPerBlister": "({{count}} pills each)",
|
||||||
|
"currentTotal": "Current total",
|
||||||
|
"newTotal": "New total",
|
||||||
|
"difference": "Difference",
|
||||||
|
"save": "Save Correction",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"success": "Stock corrected successfully"
|
||||||
|
},
|
||||||
|
"about": {
|
||||||
|
"title": "About",
|
||||||
|
"appName": "MedAssist-ng",
|
||||||
|
"description": "Open-source medication tracking and planning application for self-hosted environments.",
|
||||||
|
"version": "Version",
|
||||||
|
"frontend": "Frontend",
|
||||||
|
"backend": "Backend",
|
||||||
|
"checkForUpdates": "Check for Updates",
|
||||||
|
"checking": "Checking...",
|
||||||
|
"upToDate": "You're up to date!",
|
||||||
|
"updateAvailable": "Update available",
|
||||||
|
"viewOnGitHub": "View on GitHub",
|
||||||
|
"downloadUpdate": "Download Update",
|
||||||
|
"checkFailed": "Could not check for updates",
|
||||||
|
"lastChecked": "Last checked",
|
||||||
|
"github": "GitHub",
|
||||||
|
"license": "MIT License",
|
||||||
|
"copyright": "© {{year}} Daniel Volz",
|
||||||
|
"madeWith": "Made with ❤️ for better health management",
|
||||||
|
"techStack": "Built with React, Fastify & SQLite"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3729,6 +3729,239 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
About Modal
|
||||||
|
============================================================================= */
|
||||||
|
.about-modal {
|
||||||
|
max-width: 380px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header {
|
||||||
|
padding: 2rem 1.5rem 1.5rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 12px rgba(var(--accent-rgb, 59, 130, 246), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-logo svg {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
stroke: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-tagline {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-versions {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-row:not(:last-child) {
|
||||||
|
border-bottom: 1px dashed var(--border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-version-value {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-section {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn:hover:not(:disabled) {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-btn svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-small {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--border-primary);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result.up-to-date {
|
||||||
|
background: rgba(34, 197, 94, 0.1);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result.update-available {
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-update-result.error {
|
||||||
|
background: rgba(239, 68, 68, 0.1);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-status-text {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-status-text strong {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-download-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-download-link:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.update-last-checked {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-links {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.625rem 1.25rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-link svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-footer {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-copyright {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-license {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* =============================================================================
|
/* =============================================================================
|
||||||
Share Dialog
|
Share Dialog
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
@@ -4004,6 +4237,90 @@ h3 .reminder-icon.info-tooltip {
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =============================================================================
|
||||||
|
Edit Stock Modal (Correction)
|
||||||
|
============================================================================= */
|
||||||
|
.edit-stock-modal {
|
||||||
|
max-width: 500px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-modal h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-med-name {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--warning);
|
||||||
|
background: var(--warning-bg);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border: 1px solid rgba(252, 211, 77, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-form label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-form label .hint-text {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-form input {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row.difference.positive span:last-child {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-stock-summary .summary-row.difference.negative span:last-child {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
/* Clickable section header (for expand/collapse) */
|
/* Clickable section header (for expand/collapse) */
|
||||||
.section-header-clickable {
|
.section-header-clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
|
||||||
|
// Read version from package.json at build time
|
||||||
|
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
__APP_VERSION__: JSON.stringify(packageJson.version || "unknown"),
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user