diff --git a/backend/drizzle/0001_add_stock_adjustment.sql b/backend/drizzle/0001_add_stock_adjustment.sql new file mode 100644 index 0000000..6c8006a --- /dev/null +++ b/backend/drizzle/0001_add_stock_adjustment.sql @@ -0,0 +1 @@ +ALTER TABLE `medications` ADD `stock_adjustment` integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/backend/drizzle/0002_add_last_stock_correction_at.sql b/backend/drizzle/0002_add_last_stock_correction_at.sql new file mode 100644 index 0000000..aa2b4c6 --- /dev/null +++ b/backend/drizzle/0002_add_last_stock_correction_at.sql @@ -0,0 +1 @@ +ALTER TABLE `medications` ADD `last_stock_correction_at` integer; \ No newline at end of file diff --git a/backend/drizzle/meta/0001_snapshot.json b/backend/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..4bff0af --- /dev/null +++ b/backend/drizzle/meta/0001_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/0002_snapshot.json b/backend/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..18a01f5 --- /dev/null +++ b/backend/drizzle/meta/0002_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/backend/drizzle/meta/_journal.json b/backend/drizzle/meta/_journal.json index d58961f..e1cfeda 100644 --- a/backend/drizzle/meta/_journal.json +++ b/backend/drizzle/meta/_journal.json @@ -8,6 +8,20 @@ "when": 1768600500759, "tag": "0000_init", "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 } ] } \ No newline at end of file diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index 42a54bd..da641cc 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -75,6 +75,10 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo `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) { diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index f282651..cb9a204 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -29,7 +29,9 @@ export const medications = sqliteTable("medications", { packCount: integer("pack_count").notNull().default(1), blistersPerPack: integer("blisters_per_pack").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"), usageJson: text("usage_json").notNull().default("[]"), everyJson: text("every_json").notNull().default("[]"), diff --git a/backend/src/routes/medications.ts b/backend/src/routes/medications.ts index 8c65cc4..f24c9c6 100644 --- a/backend/src/routes/medications.ts +++ b/backend/src/routes/medications.ts @@ -96,6 +96,8 @@ export async function medicationRoutes(app: FastifyInstance) { blistersPerPack: row.blistersPerPack ?? 1, pillsPerBlister: row.pillsPerBlister ?? 1, looseTablets: row.looseTablets ?? 0, + stockAdjustment: row.stockAdjustment ?? 0, + lastStockCorrectionAt: row.lastStockCorrectionAt?.toISOString() ?? null, pillWeightMg: row.pillWeightMg, blisters: parseBlisters(row), imageUrl: row.imageUrl, @@ -147,6 +149,8 @@ export async function medicationRoutes(app: FastifyInstance) { blistersPerPack: inserted.blistersPerPack, pillsPerBlister: inserted.pillsPerBlister, looseTablets: inserted.looseTablets, + stockAdjustment: inserted.stockAdjustment ?? 0, + lastStockCorrectionAt: inserted.lastStockCorrectionAt?.toISOString() ?? null, pillWeightMg: inserted.pillWeightMg, blisters, imageUrl: inserted.imageUrl, @@ -235,6 +239,8 @@ export async function medicationRoutes(app: FastifyInstance) { blistersPerPack: result[0].blistersPerPack, pillsPerBlister: result[0].pillsPerBlister, looseTablets: result[0].looseTablets, + stockAdjustment: result[0].stockAdjustment ?? 0, + lastStockCorrectionAt: result[0].lastStockCorrectionAt?.toISOString() ?? null, pillWeightMg: result[0].pillWeightMg, blisters, 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) => { const idNum = Number(req.params.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 blistersPerPack = row.blistersPerPack ?? 1; 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) let consumedUntilNow = 0; diff --git a/backend/src/routes/share.ts b/backend/src/routes/share.ts index f13c7ac..00ca9e6 100644 --- a/backend/src/routes/share.ts +++ b/backend/src/routes/share.ts @@ -113,7 +113,7 @@ export async function shareRoutes(app: FastifyInstance) { // Parse takenBy JSON array 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 { id: med.id, name: med.name, diff --git a/backend/src/services/reminder-scheduler.ts b/backend/src/services/reminder-scheduler.ts index afd3e25..0dce9e2 100644 --- a/backend/src/services/reminder-scheduler.ts +++ b/backend/src/services/reminder-scheduler.ts @@ -93,7 +93,7 @@ async function getMedicationsNeedingReminder(userId: number, reminderDaysBefore: for (const row of rows) { 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); // Check if medication runs out within reminderDaysBefore days diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index 9ab7412..4d6d55f 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -85,6 +85,8 @@ async function createSchema(client: Client) { blisters_per_pack integer NOT NULL DEFAULT 1, pills_per_blister integer NOT NULL DEFAULT 1, loose_tablets integer NOT NULL DEFAULT 0, + stock_adjustment integer NOT NULL DEFAULT 0, + last_stock_correction_at integer, pill_weight_mg integer, usage_json text NOT NULL DEFAULT '[]', every_json text NOT NULL DEFAULT '[]', diff --git a/backend/src/test/integration.test.ts b/backend/src/test/integration.test.ts index 164533a..923cfe7 100644 --- a/backend/src/test/integration.test.ts +++ b/backend/src/test/integration.test.ts @@ -80,6 +80,8 @@ async function createSchema(client: Client) { blisters_per_pack integer NOT NULL DEFAULT 1, pills_per_blister integer NOT NULL DEFAULT 1, loose_tablets integer NOT NULL DEFAULT 0, + stock_adjustment integer NOT NULL DEFAULT 0, + last_stock_correction_at integer, pill_weight_mg integer, usage_json text NOT NULL DEFAULT '[]', every_json text NOT NULL DEFAULT '[]', diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 776c368..7fb5b9a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,8 @@ type Medication = { blistersPerPack: number; pillsPerBlister: number; looseTablets: number; + stockAdjustment?: number; + lastStockCorrectionAt?: string | null; // When stock was last corrected - consumed doses before this don't count pillWeightMg?: number | null; blisters: Blister[]; imageUrl?: string | null; @@ -27,6 +29,11 @@ type Medication = { 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); +} + type PlannerRow = { medicationId: number; medicationName: string; @@ -381,6 +388,11 @@ function AppContent() { const [refillSaving, setRefillSaving] = useState(false); const [refillHistory, setRefillHistory] = useState([]); 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) const [manuallyCollapsedDays, setManuallyCollapsedDays] = useState>(new Set()); const [manuallyExpandedDays, setManuallyExpandedDays] = useState>(new Set()); @@ -541,6 +553,8 @@ function AppContent() { closeScheduleLightbox(); } else if (showImageLightbox) { closeImageLightbox(); + } else if (showEditStockModal) { + closeEditStockModal(); } else if (showRefillModal) { closeRefillModal(); } else if (showEditModal) { @@ -559,7 +573,7 @@ function AppContent() { }; document.addEventListener("keydown", handleEscape); return () => document.removeEventListener("keydown", handleEscape); - }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, showRefillModal, userDropdownOpen]); + }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, showRefillModal, showEditStockModal, userDropdownOpen]); // Handle browser back button to close modals (in priority order) useEffect(() => { @@ -571,6 +585,8 @@ function AppContent() { setShowImageLightbox(false); } else if (scheduleLightboxImage) { setScheduleLightboxImage(null); + } else if (showEditStockModal) { + setShowEditStockModal(false); } else if (showRefillModal) { setShowRefillModal(false); } else if (showEditModal) { @@ -588,7 +604,7 @@ function AppContent() { }; window.addEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState); - }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, showRefillModal]); + }, [selectedMed, showImageLightbox, scheduleLightboxImage, selectedUser, showProfile, showShareDialog, showEditModal, showRefillModal, showEditStockModal]); // Close user dropdown when clicking outside useEffect(() => { @@ -909,6 +925,72 @@ function AppContent() { 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 function openMedDetail(med: Medication) { setSelectedMed(med); @@ -957,6 +1039,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() { setShowEditModal(true); window.history.pushState({ modal: 'edit' }, ''); @@ -1663,7 +1768,7 @@ function AppContent() { Math.round(row.medsLeft), med?.pillsPerBlister ?? 1, med?.looseTablets ?? 0, - med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft) + med ? getMedTotal(med) : Math.round(row.medsLeft) ); return (
med && openMedDetail(med)}> @@ -1732,7 +1837,7 @@ function AppContent() { Math.round(row.medsLeft), med?.pillsPerBlister ?? 1, med?.looseTablets ?? 0, - med ? med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets : Math.round(row.medsLeft) + med ? getMedTotal(med) : Math.round(row.medsLeft) ); return (
med && openMedDetail(med)}> @@ -2101,7 +2206,7 @@ function AppContent() { {t('medications.details.pillsPerBlister')}: {med.pillsPerBlister} {t('medications.details.loose')}: {med.looseTablets}
-
{t('medications.details.total')}: {med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets} {t('common.pills')}
+
{t('medications.details.total')}: {getMedTotal(med)} {t('common.pills')}
@@ -2973,7 +3078,7 @@ function AppContent() { style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}} >
- 📦 {t('exportImport.exportWithImages')} + {t('exportImport.exportWithImages')} {t('exportImport.exportWithImagesDesc')}
@@ -2988,7 +3093,7 @@ function AppContent() { style={{textAlign: 'left', cursor: 'pointer', border: '1px solid var(--border)', borderRadius: '8px'}} >
- 📄 {t('exportImport.exportDataOnly')} + {t('exportImport.exportDataOnly')} {t('exportImport.exportDataOnlyDesc')}
@@ -3222,7 +3327,7 @@ function AppContent() {

{t('modal.stockInfo')}

{(() => { const medCoverage = coverage.all.find(c => c.name === selectedMed.name); - const totalStock = selectedMed.packCount * selectedMed.blistersPerPack * selectedMed.pillsPerBlister + selectedMed.looseTablets; + const totalStock = getMedTotal(selectedMed); const currentStock = medCoverage ? Math.round(medCoverage.medsLeft) : totalStock; const status = medCoverage ? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings) : null; const textClass = status?.className === "danger" ? "danger-text" : status?.className === "warning" ? "warning-text" : "success-text"; @@ -3371,7 +3476,7 @@ function AppContent() { - {selectedMed.blisters.length > 0 && ( @@ -3445,6 +3550,88 @@ function AppContent() {
)} + + {/* Edit Stock Modal */} + {showEditStockModal && ( +
{ e.stopPropagation(); closeEditStockModal(); }}> +
e.stopPropagation()}> + +

{t('editStock.title')}

+

{selectedMed.name}

+

{t('editStock.hint')}

+ + {(() => { + // 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 ( + <> +
+ + +
+ +
+
+ {t('editStock.currentTotal')}: + {currentTotal} {t('common.pills')} +
+
+ {t('editStock.newTotal')}: + {newTotal} {t('common.pills')} +
+
0 ? 'positive' : difference < 0 ? 'negative' : ''}`}> + {t('editStock.difference')}: + {difference > 0 ? '+' : ''}{difference} {t('common.pills')} +
+
+ + ); + })()} + +
+ + +
+
+
+ )} )} @@ -3463,7 +3650,7 @@ function AppContent() { {meds.filter(m => (m.takenBy || []).includes(selectedUser)).map((med) => { const medCoverage = coverage.all.find(c => c.name === med.name); 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); return (
string): s 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( openBlisterPills: number, - loosePills: number, + _loosePills: number, pillsPerBlister: number, t: (key: string) => string ): string { - // Format open blister part - const openBlisterText = openBlisterPills > 0 - ? `${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')}`; + if (openBlisterPills > 0) { + return `${openBlisterPills} ${t('common.of')} ${pillsPerBlister} ${t('common.pills')}`; } - - // No loose pills - if (openBlisterPills === 0) return "—"; - return openBlisterText; + return "—"; } function getExpiryClass(expiryDate: string | null | undefined, expiryWarningDays: number = 30): string { @@ -4056,14 +4222,19 @@ function calculateCoverage( 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") { - // 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 m.blisters.forEach((s) => { - const start = new Date(s.start).getTime(); - if (Number.isNaN(start) || start > now) return; + const blisterStart = new Date(s.start).getTime(); + // 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 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; }); } else { @@ -4076,9 +4247,11 @@ function calculateCoverage( const blisterIdx = parseInt(parts[1], 10); const doseTimestamp = parseInt(parts[2], 10); 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(); - if (!Number.isNaN(blisterStart) && doseTimestamp >= blisterStart) { + if (!Number.isNaN(blisterStart) && doseTimestamp >= blisterStart && doseTimestamp > stockCorrectionCutoff) { // Each taken dose (regardless of person) consumes the usage amount consumed += m.blisters[blisterIdx].usage; } @@ -4087,7 +4260,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 rawDaysLeft = dailyRate > 0 ? medsLeft / dailyRate : null; const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null; // conservative: round down @@ -4653,7 +4826,7 @@ function SharedSchedule() { } 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 currentCount = Math.max(0, totalCount - taken); // Calculate daily usage from blisters, multiplied by number of people diff --git a/frontend/src/i18n/de.json b/frontend/src/i18n/de.json index c0cf874..5f3a452 100644 --- a/frontend/src/i18n/de.json +++ b/frontend/src/i18n/de.json @@ -330,7 +330,8 @@ "fullBlister": "voller Blister", "fullBlisters": "volle Blister", "inBlister": "in 1 Blister", - "total": "gesamt" + "total": "gesamt", + "max": "max" }, "share": { "button": "Teilen", @@ -406,5 +407,18 @@ "pillsAdded": "{{count}} Tablette", "pillsAdded_other": "{{count}} Tabletten", "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" } } diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 3b9e60d..09fb415 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -332,7 +332,8 @@ "fullBlister": "full blister", "fullBlisters": "full blisters", "inBlister": "in 1 blister", - "total": "total" + "total": "total", + "max": "max" }, "share": { "button": "Share", @@ -408,5 +409,18 @@ "pillsAdded": "{{count}} pill", "pillsAdded_other": "{{count}} pills", "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" } } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 6674fcb..76648f3 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -4004,6 +4004,90 @@ h3 .reminder-icon.info-tooltip { 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) */ .section-header-clickable { cursor: pointer;