feat(medications): enhance medication stock calculation and improve UI layout for better readability

This commit is contained in:
Daniel Volz
2025-12-26 23:06:02 +01:00
parent be1e8cda18
commit 68660202cf
3 changed files with 60 additions and 9 deletions
+30 -6
View File
@@ -302,6 +302,8 @@ export async function medicationRoutes(app: FastifyInstance) {
const userId = getUserId(req);
const rows = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const now = new Date();
const payload = rows.map((row) => {
const slices = parseSlices(row);
const usageTotal = calculateUsageInRange(slices, start, end);
@@ -309,17 +311,39 @@ export async function medicationRoutes(app: FastifyInstance) {
const packCount = row.packCount ?? 1;
const stripsPerPack = row.stripsPerPack ?? row.strips ?? 1;
const looseTablets = row.looseTablets ?? 0;
const totalPills = row.count;
const originalTotalPills = packCount * stripsPerPack * tabsPerStrip + looseTablets;
// Calculate consumption up to now (same logic as frontend)
let consumedUntilNow = 0;
slices.forEach((slice) => {
const sliceStart = new Date(slice.start);
if (Number.isNaN(sliceStart.getTime()) || sliceStart > now) return;
const msPerDay = 86400000;
const period = Math.max(1, slice.every) * msPerDay;
const occurrences = Math.floor((now.getTime() - sliceStart.getTime()) / period) + 1;
consumedUntilNow += occurrences * slice.usage;
});
const currentPills = Math.max(0, originalTotalPills - consumedUntilNow);
const stripsNeeded = tabsPerStrip > 0 ? Math.ceil(usageTotal / tabsPerStrip) : 0;
const fullBlisters = packCount * stripsPerPack;
const loosePills = looseTablets;
const totalAvailablePills = fullBlisters * tabsPerStrip + loosePills;
const enough = totalAvailablePills >= usageTotal;
// Calculate current stock using realistic consumption order (loose first, then blisters)
const consumed = originalTotalPills - currentPills;
const looseConsumed = Math.min(consumed, looseTablets);
const loosePillsRemaining = looseTablets - looseConsumed;
const blisterPillsConsumed = consumed - looseConsumed;
const originalBlisterPills = originalTotalPills - looseTablets;
const blisterPillsRemaining = Math.max(0, originalBlisterPills - blisterPillsConsumed);
const fullBlisters = tabsPerStrip > 0 ? Math.floor(blisterPillsRemaining / tabsPerStrip) : 0;
const openBlisterPills = tabsPerStrip > 0 ? blisterPillsRemaining % tabsPerStrip : 0;
const loosePills = loosePillsRemaining + openBlisterPills; // Combine open blister + remaining loose
const enough = currentPills >= usageTotal;
return {
medicationId: row.id,
medicationName: row.name,
totalPills,
totalPills: currentPills,
plannerUsage: usageTotal,
stripSize: tabsPerStrip,
stripsNeeded,
+1 -1
View File
@@ -1293,7 +1293,7 @@ function AppContent() {
return (
<div key={row.medicationId} className="table-row clickable" onClick={() => med && setSelectedMed(med)}>
<span data-label={t('planner.table.medication')} className="cell-with-avatar"><MedicationAvatar name={row.medicationName} imageUrl={med?.imageUrl} />{row.medicationName}</span>
<span data-label={t('planner.table.usage')}><strong>{row.plannerUsage}</strong> {t('common.pills')}</span>
<span data-label={t('planner.table.usage')}><strong>{row.plannerUsage}</strong>&nbsp;{t('common.pills')}</span>
<span data-label={t('planner.table.blisters')}>{row.stripsNeeded} × {row.stripSize}</span>
<span data-label={t('planner.table.available')}>
{row.fullBlisters} {t('common.blisters')}{row.loosePills > 0 && ` + ${row.loosePills} ${t('common.pills')}`}
+29 -2
View File
@@ -179,7 +179,7 @@ body {
border-radius: 14px;
padding: 1.25rem;
box-shadow: 0 14px 36px var(--shadow);
overflow-x: auto;
overflow: hidden;
transition: background 200ms ease, border-color 200ms ease;
}
@@ -575,7 +575,7 @@ textarea {
.card p { margin: 0; }
.table { width: 100%; display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; overflow-x: auto; }
.table { width: 100%; display: flex; flex-direction: column; gap: 0; margin-top: 0.5rem; }
.table-head, .table-row {
display: grid;
grid-template-columns: minmax(180px, 2fr) 100px 140px 140px 120px;
@@ -696,6 +696,33 @@ textarea {
margin-right: 1rem;
flex-shrink: 0;
}
/* First span (name cell) - centered horizontal layout */
.table-row span:first-child {
justify-content: center;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border-primary);
margin-bottom: 0.25rem;
}
.table-row span:first-child::before {
display: none; /* Hide "NAME" label on mobile */
}
/* Avatar + name layout - horizontal centered */
.table-row .cell-with-avatar {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.4rem;
}
.table-row .cell-with-avatar .med-avatar {
flex-shrink: 0;
}
/* Badges and icons wrap to next line if needed but stay together */
.table-row .taken-by-badge,
.table-row .reminder-icon,
.table-row .notes-icon {
flex-shrink: 0;
}
.table-4 .table-head, .table-4 .table-row,
.table-5 .table-head, .table-5 .table-row,
.table-6 .table-head, .table-6 .table-row,