feat(medications): the table's stock column projects its runway

Under the dose count the column now shows roughly how many days the
remaining supply covers — the same coarse schedule-derived estimate the
detail overview renders, so the refill question is answered at a
glance. (#316)
This commit is contained in:
Marc-André Bombeck committed 2026-06-12 21:44:25 +02:00
1 parent 57eafabb9d
commit 45e391c60a
7 files changed
+375 -18

No files matched your search

+57 -1
View File
@@ -664,7 +664,58 @@
"bulkDeleteSuccess": "{count} Einträge gelöscht",
"bulkDeleteError": "Fehler beim Löschen der ausgewählten Einträge",
"bulkDeleteConfirmTitle": "{count} Einträge löschen?",
"bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden endgültig gelöscht."
"bulkDeleteConfirmBody": "Die {count} ausgewählten Stimmungseinträge werden endgültig gelöscht.",
"customize": "Stimmungs-Tags anpassen",
"addTagInline": "Tag hinzufügen",
"manage": {
"groupsTitle": "Gruppen",
"groupsDescription": "Lege eigene Gruppen an und bestimme ihre Reihenfolge in der Auswahl.",
"newGroup": "Neue Gruppe",
"groupLabel": "Gruppenname",
"groupIcon": "Symbol",
"groupCreated": "Gruppe erstellt",
"groupRenamed": "Gruppe gespeichert",
"groupDeleteTitle": "Gruppe löschen?",
"groupDeleteBody": "Tags aus dieser Gruppe wandern zurück in ihre ursprüngliche Gruppe. Es werden keine Einträge gelöscht.",
"groupDeleted": "Gruppe gelöscht",
"groupEmpty": "Noch keine eigenen Gruppen.",
"groupLimitReached": "Gruppen-Limit erreicht",
"tagsTitle": "Tags",
"tagsDescription": "Blende Tags aus, sortiere und verschiebe sie oder bearbeite sie.",
"newTag": "Neuer Tag",
"tagLabel": "Tag-Name",
"tagIcon": "Symbol",
"tagGroup": "Gruppe",
"usageCount": "{count} Einträge",
"hideTag": "Tag ausblenden",
"showTag": "Tag einblenden",
"moveToGroup": "In Gruppe verschieben",
"archive": "Archivieren",
"restore": "Wiederherstellen",
"archivedTitle": "Archivierte Tags",
"archivedDescription": "Archivierte Tags bleiben in deinen bisherigen Einträgen erhalten – sie verschwinden nur aus der Auswahl.",
"archivedEmpty": "Nichts archiviert.",
"purgeTitle": "Tag endgültig löschen?",
"purgeBody": "Der Tag wird aus {count} bisherigen Einträgen entfernt. Das lässt sich nicht rückgängig machen.",
"purgeAction": "Endgültig löschen",
"purged": "Tag gelöscht",
"tagLimitReached": "Tag-Limit erreicht",
"iconPickerTitle": "Symbol wählen",
"iconSearchPlaceholder": "Symbole durchsuchen…",
"iconSearchEmpty": "Kein passendes Symbol.",
"iconSearchClear": "Suche leeren",
"orderSave": "Reihenfolge speichern",
"orderSaved": "Reihenfolge gespeichert",
"moveUp": "Nach oben",
"moveDown": "Nach unten",
"iconGroupEmotions": "Gefühle",
"iconGroupActivities": "Aktivitäten",
"iconGroupHealth": "Gesundheit",
"iconGroupFood": "Essen & Trinken",
"iconGroupWeather": "Wetter",
"iconGroupPlaces": "Orte",
"iconGroupMisc": "Sonstiges"
}
},
"medications": {
"title": "Medikamente",
@@ -1749,6 +1800,7 @@
"tableComplianceDays": "{days} T",
"tableStockDoseOne": "1 Dosis",
"tableStockDoses": "{count} Dosen",
"tableStockRunway": "≈ {days} Tage übrig",
"takeActionFor": "Einnehmen – {name}",
"skipActionFor": "Überspringen – {name}",
"reorderTitle": "Reihenfolge anpassen",
@@ -3789,6 +3841,10 @@
"medications": {
"title": "Medikamente",
"description": "Listenansicht und Reihenfolge der Medikamente."
},
"mood": {
"title": "Stimmungs-Tags",
"description": "Gruppen, Tags und Reihenfolge der Tag-Auswahl."
}
},
"profile": "Profil",
+57 -1
View File
@@ -664,7 +664,58 @@
"bulkDeleteSuccess": "{count} entries deleted",
"bulkDeleteError": "Error deleting the selected entries",
"bulkDeleteConfirmTitle": "Delete {count} entries?",
"bulkDeleteConfirmBody": "The {count} selected mood entries will be permanently deleted."
"bulkDeleteConfirmBody": "The {count} selected mood entries will be permanently deleted.",
"customize": "Customize mood tags",
"addTagInline": "Add tag",
"manage": {
"groupsTitle": "Groups",
"groupsDescription": "Create your own groups and set the order the picker shows them in.",
"newGroup": "New group",
"groupLabel": "Group name",
"groupIcon": "Icon",
"groupCreated": "Group created",
"groupRenamed": "Group saved",
"groupDeleteTitle": "Delete group?",
"groupDeleteBody": "Tags in this group move back to their original group. No entries are deleted.",
"groupDeleted": "Group deleted",
"groupEmpty": "No own groups yet.",
"groupLimitReached": "Group limit reached",
"tagsTitle": "Tags",
"tagsDescription": "Hide, rearrange, move, or edit the tags the picker shows.",
"newTag": "New tag",
"tagLabel": "Tag name",
"tagIcon": "Icon",
"tagGroup": "Group",
"usageCount": "{count} entries",
"hideTag": "Hide tag",
"showTag": "Show tag",
"moveToGroup": "Move to group",
"archive": "Archive",
"restore": "Restore",
"archivedTitle": "Archived tags",
"archivedDescription": "Archived tags stay in your past entries — they only disappear from the picker.",
"archivedEmpty": "Nothing archived.",
"purgeTitle": "Delete tag permanently?",
"purgeBody": "This removes the tag from {count} past entries. This cannot be undone.",
"purgeAction": "Delete permanently",
"purged": "Tag deleted",
"tagLimitReached": "Tag limit reached",
"iconPickerTitle": "Choose an icon",
"iconSearchPlaceholder": "Search icons…",
"iconSearchEmpty": "No icon matches.",
"iconSearchClear": "Clear search",
"orderSave": "Save order",
"orderSaved": "Order saved",
"moveUp": "Move up",
"moveDown": "Move down",
"iconGroupEmotions": "Emotions",
"iconGroupActivities": "Activities",
"iconGroupHealth": "Health",
"iconGroupFood": "Food & drink",
"iconGroupWeather": "Weather",
"iconGroupPlaces": "Places",
"iconGroupMisc": "Other"
}
},
"medications": {
"title": "Medications",
@@ -1749,6 +1800,7 @@
"tableComplianceDays": "{days} d",
"tableStockDoseOne": "1 dose",
"tableStockDoses": "{count} doses",
"tableStockRunway": "≈ {days} days left",
"takeActionFor": "Take – {name}",
"skipActionFor": "Skip – {name}",
"reorderTitle": "Adjust order",
@@ -3789,6 +3841,10 @@
"medications": {
"title": "Medications",
"description": "List view and medication order."
},
"mood": {
"title": "Mood tags",
"description": "Groups, tags, and order of the mood picker."
}
},
"profile": "Profile",
+57 -1
View File
@@ -664,7 +664,58 @@
"bulkDeleteSuccess": "{count} entradas eliminadas",
"bulkDeleteError": "Error al eliminar las entradas seleccionadas",
"bulkDeleteConfirmTitle": "¿Eliminar {count} entradas?",
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán de forma permanente."
"bulkDeleteConfirmBody": "Las {count} entradas de estado de ánimo seleccionadas se eliminarán de forma permanente.",
"customize": "Personalizar etiquetas de ánimo",
"addTagInline": "Añadir etiqueta",
"manage": {
"groupsTitle": "Grupos",
"groupsDescription": "Crea tus propios grupos y define su orden en el selector.",
"newGroup": "Nuevo grupo",
"groupLabel": "Nombre del grupo",
"groupIcon": "Icono",
"groupCreated": "Grupo creado",
"groupRenamed": "Grupo guardado",
"groupDeleteTitle": "¿Eliminar grupo?",
"groupDeleteBody": "Las etiquetas de este grupo vuelven a su grupo original. No se elimina ninguna entrada.",
"groupDeleted": "Grupo eliminado",
"groupEmpty": "Aún no hay grupos propios.",
"groupLimitReached": "Límite de grupos alcanzado",
"tagsTitle": "Etiquetas",
"tagsDescription": "Oculta, reordena, mueve o edita las etiquetas del selector.",
"newTag": "Nueva etiqueta",
"tagLabel": "Nombre de la etiqueta",
"tagIcon": "Icono",
"tagGroup": "Grupo",
"usageCount": "{count} entradas",
"hideTag": "Ocultar etiqueta",
"showTag": "Mostrar etiqueta",
"moveToGroup": "Mover a grupo",
"archive": "Archivar",
"restore": "Restaurar",
"archivedTitle": "Etiquetas archivadas",
"archivedDescription": "Las etiquetas archivadas permanecen en tus entradas anteriores; solo desaparecen del selector.",
"archivedEmpty": "Nada archivado.",
"purgeTitle": "¿Eliminar etiqueta definitivamente?",
"purgeBody": "Esto elimina la etiqueta de {count} entradas anteriores. No se puede deshacer.",
"purgeAction": "Eliminar definitivamente",
"purged": "Etiqueta eliminada",
"tagLimitReached": "Límite de etiquetas alcanzado",
"iconPickerTitle": "Elegir icono",
"iconSearchPlaceholder": "Buscar iconos…",
"iconSearchEmpty": "Ningún icono coincide.",
"iconSearchClear": "Borrar búsqueda",
"orderSave": "Guardar orden",
"orderSaved": "Orden guardado",
"moveUp": "Subir",
"moveDown": "Bajar",
"iconGroupEmotions": "Emociones",
"iconGroupActivities": "Actividades",
"iconGroupHealth": "Salud",
"iconGroupFood": "Comida y bebida",
"iconGroupWeather": "Tiempo",
"iconGroupPlaces": "Lugares",
"iconGroupMisc": "Otros"
}
},
"medications": {
"title": "Medicamentos",
@@ -1749,6 +1800,7 @@
"tableComplianceDays": "{days} d",
"tableStockDoseOne": "1 dosis",
"tableStockDoses": "{count} dosis",
"tableStockRunway": "≈ {days} días restantes",
"takeActionFor": "Tomar – {name}",
"skipActionFor": "Omitir – {name}",
"reorderTitle": "Ajustar orden",
@@ -3789,6 +3841,10 @@
"medications": {
"title": "Medicamentos",
"description": "Vista de lista y orden de los medicamentos."
},
"mood": {
"title": "Etiquetas de ánimo",
"description": "Grupos, etiquetas y orden del selector de ánimo."
}
},
"profile": "Perfil",
+57 -1
View File
@@ -664,7 +664,58 @@
"bulkDeleteSuccess": "{count} entrées supprimées",
"bulkDeleteError": "Erreur lors de la suppression des entrées sélectionnées",
"bulkDeleteConfirmTitle": "Supprimer {count} entrées ?",
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront définitivement supprimées."
"bulkDeleteConfirmBody": "Les {count} entrées d'humeur sélectionnées seront définitivement supprimées.",
"customize": "Personnaliser les étiquettes d’humeur",
"addTagInline": "Ajouter une étiquette",
"manage": {
"groupsTitle": "Groupes",
"groupsDescription": "Crée tes propres groupes et définis leur ordre dans le sélecteur.",
"newGroup": "Nouveau groupe",
"groupLabel": "Nom du groupe",
"groupIcon": "Icône",
"groupCreated": "Groupe créé",
"groupRenamed": "Groupe enregistré",
"groupDeleteTitle": "Supprimer le groupe ?",
"groupDeleteBody": "Les étiquettes de ce groupe reviennent dans leur groupe d’origine. Aucune saisie n’est supprimée.",
"groupDeleted": "Groupe supprimé",
"groupEmpty": "Pas encore de groupe personnel.",
"groupLimitReached": "Limite de groupes atteinte",
"tagsTitle": "Étiquettes",
"tagsDescription": "Masque, réordonne, déplace ou modifie les étiquettes du sélecteur.",
"newTag": "Nouvelle étiquette",
"tagLabel": "Nom de l’étiquette",
"tagIcon": "Icône",
"tagGroup": "Groupe",
"usageCount": "{count} saisies",
"hideTag": "Masquer l’étiquette",
"showTag": "Afficher l’étiquette",
"moveToGroup": "Déplacer vers un groupe",
"archive": "Archiver",
"restore": "Restaurer",
"archivedTitle": "Étiquettes archivées",
"archivedDescription": "Les étiquettes archivées restent dans tes saisies passées — elles disparaissent seulement du sélecteur.",
"archivedEmpty": "Rien d’archivé.",
"purgeTitle": "Supprimer l’étiquette définitivement ?",
"purgeBody": "L’étiquette sera retirée de {count} saisies passées. Cette action est irréversible.",
"purgeAction": "Supprimer définitivement",
"purged": "Étiquette supprimée",
"tagLimitReached": "Limite d’étiquettes atteinte",
"iconPickerTitle": "Choisir une icône",
"iconSearchPlaceholder": "Rechercher des icônes…",
"iconSearchEmpty": "Aucune icône ne correspond.",
"iconSearchClear": "Effacer la recherche",
"orderSave": "Enregistrer l’ordre",
"orderSaved": "Ordre enregistré",
"moveUp": "Monter",
"moveDown": "Descendre",
"iconGroupEmotions": "Émotions",
"iconGroupActivities": "Activités",
"iconGroupHealth": "Santé",
"iconGroupFood": "Nourriture et boissons",
"iconGroupWeather": "Météo",
"iconGroupPlaces": "Lieux",
"iconGroupMisc": "Autres"
}
},
"medications": {
"title": "Médicaments",
@@ -1749,6 +1800,7 @@
"tableComplianceDays": "{days} j",
"tableStockDoseOne": "1 dose",
"tableStockDoses": "{count} doses",
"tableStockRunway": "≈ {days} jours restants",
"takeActionFor": "Prendre – {name}",
"skipActionFor": "Passer – {name}",
"reorderTitle": "Modifier l'ordre",
@@ -3789,6 +3841,10 @@
"medications": {
"title": "Médicaments",
"description": "Vue de la liste et ordre des médicaments."
},
"mood": {
"title": "Étiquettes d’humeur",
"description": "Groupes, étiquettes et ordre du sélecteur d’humeur."
}
},
"profile": "Profil",
+57 -1
View File
@@ -664,7 +664,58 @@
"bulkDeleteSuccess": "{count} voci eliminate",
"bulkDeleteError": "Errore durante l'eliminazione delle voci selezionate",
"bulkDeleteConfirmTitle": "Eliminare {count} voci?",
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate definitivamente."
"bulkDeleteConfirmBody": "Le {count} voci dell'umore selezionate verranno eliminate definitivamente.",
"customize": "Personalizza i tag dell’umore",
"addTagInline": "Aggiungi tag",
"manage": {
"groupsTitle": "Gruppi",
"groupsDescription": "Crea i tuoi gruppi e definisci il loro ordine nel selettore.",
"newGroup": "Nuovo gruppo",
"groupLabel": "Nome del gruppo",
"groupIcon": "Icona",
"groupCreated": "Gruppo creato",
"groupRenamed": "Gruppo salvato",
"groupDeleteTitle": "Eliminare il gruppo?",
"groupDeleteBody": "I tag di questo gruppo tornano nel loro gruppo originale. Nessuna voce viene eliminata.",
"groupDeleted": "Gruppo eliminato",
"groupEmpty": "Ancora nessun gruppo personale.",
"groupLimitReached": "Limite di gruppi raggiunto",
"tagsTitle": "Tag",
"tagsDescription": "Nascondi, riordina, sposta o modifica i tag del selettore.",
"newTag": "Nuovo tag",
"tagLabel": "Nome del tag",
"tagIcon": "Icona",
"tagGroup": "Gruppo",
"usageCount": "{count} voci",
"hideTag": "Nascondi tag",
"showTag": "Mostra tag",
"moveToGroup": "Sposta nel gruppo",
"archive": "Archivia",
"restore": "Ripristina",
"archivedTitle": "Tag archiviati",
"archivedDescription": "I tag archiviati restano nelle tue voci passate — scompaiono solo dal selettore.",
"archivedEmpty": "Niente in archivio.",
"purgeTitle": "Eliminare il tag definitivamente?",
"purgeBody": "Il tag verrà rimosso da {count} voci passate. L’operazione non può essere annullata.",
"purgeAction": "Elimina definitivamente",
"purged": "Tag eliminato",
"tagLimitReached": "Limite di tag raggiunto",
"iconPickerTitle": "Scegli un’icona",
"iconSearchPlaceholder": "Cerca icone…",
"iconSearchEmpty": "Nessuna icona corrisponde.",
"iconSearchClear": "Cancella ricerca",
"orderSave": "Salva ordine",
"orderSaved": "Ordine salvato",
"moveUp": "Sposta su",
"moveDown": "Sposta giù",
"iconGroupEmotions": "Emozioni",
"iconGroupActivities": "Attività",
"iconGroupHealth": "Salute",
"iconGroupFood": "Cibo e bevande",
"iconGroupWeather": "Meteo",
"iconGroupPlaces": "Luoghi",
"iconGroupMisc": "Altro"
}
},
"medications": {
"title": "Farmaci",
@@ -1749,6 +1800,7 @@
"tableComplianceDays": "{days} g",
"tableStockDoseOne": "1 dose",
"tableStockDoses": "{count} dosi",
"tableStockRunway": "≈ {days} giorni rimanenti",
"takeActionFor": "Assumi – {name}",
"skipActionFor": "Salta – {name}",
"reorderTitle": "Modifica ordine",
@@ -3789,6 +3841,10 @@
"medications": {
"title": "Farmaci",
"description": "Vista elenco e ordine dei farmaci."
},
"mood": {
"title": "Tag dell’umore",
"description": "Gruppi, tag e ordine del selettore dell’umore."
}
},
"profile": "Profilo",
+57 -1
View File
@@ -664,7 +664,58 @@
"bulkDeleteSuccess": "Usunięto wpisy: {count}",
"bulkDeleteError": "Błąd podczas usuwania wybranych wpisów",
"bulkDeleteConfirmTitle": "Usunąć wpisy ({count})?",
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną trwale usunięte."
"bulkDeleteConfirmBody": "Wybrane wpisy nastroju ({count}) zostaną trwale usunięte.",
"customize": "Dostosuj tagi nastroju",
"addTagInline": "Dodaj tag",
"manage": {
"groupsTitle": "Grupy",
"groupsDescription": "Twórz własne grupy i ustal ich kolejność w wyborze.",
"newGroup": "Nowa grupa",
"groupLabel": "Nazwa grupy",
"groupIcon": "Ikona",
"groupCreated": "Grupa utworzona",
"groupRenamed": "Grupa zapisana",
"groupDeleteTitle": "Usunąć grupę?",
"groupDeleteBody": "Tagi z tej grupy wracają do swojej pierwotnej grupy. Żadne wpisy nie zostaną usunięte.",
"groupDeleted": "Grupa usunięta",
"groupEmpty": "Brak własnych grup.",
"groupLimitReached": "Osiągnięto limit grup",
"tagsTitle": "Tagi",
"tagsDescription": "Ukrywaj, porządkuj, przenoś lub edytuj tagi widoczne w wyborze.",
"newTag": "Nowy tag",
"tagLabel": "Nazwa tagu",
"tagIcon": "Ikona",
"tagGroup": "Grupa",
"usageCount": "{count} wpisów",
"hideTag": "Ukryj tag",
"showTag": "Pokaż tag",
"moveToGroup": "Przenieś do grupy",
"archive": "Archiwizuj",
"restore": "Przywróć",
"archivedTitle": "Zarchiwizowane tagi",
"archivedDescription": "Zarchiwizowane tagi pozostają w Twoich wcześniejszych wpisach — znikają tylko z wyboru.",
"archivedEmpty": "Nic nie zarchiwizowano.",
"purgeTitle": "Usunąć tag na zawsze?",
"purgeBody": "Tag zostanie usunięty z {count} wcześniejszych wpisów. Tej operacji nie można cofnąć.",
"purgeAction": "Usuń na zawsze",
"purged": "Tag usunięty",
"tagLimitReached": "Osiągnięto limit tagów",
"iconPickerTitle": "Wybierz ikonę",
"iconSearchPlaceholder": "Szukaj ikon…",
"iconSearchEmpty": "Brak pasującej ikony.",
"iconSearchClear": "Wyczyść wyszukiwanie",
"orderSave": "Zapisz kolejność",
"orderSaved": "Kolejność zapisana",
"moveUp": "W górę",
"moveDown": "W dół",
"iconGroupEmotions": "Emocje",
"iconGroupActivities": "Aktywności",
"iconGroupHealth": "Zdrowie",
"iconGroupFood": "Jedzenie i napoje",
"iconGroupWeather": "Pogoda",
"iconGroupPlaces": "Miejsca",
"iconGroupMisc": "Inne"
}
},
"medications": {
"title": "Leki",
@@ -1749,6 +1800,7 @@
"tableComplianceDays": "{days} dni",
"tableStockDoseOne": "1 dawka",
"tableStockDoses": "Dawki: {count}",
"tableStockRunway": "≈ {days} dni zapasu",
"takeActionFor": "Przyjmij – {name}",
"skipActionFor": "Pomiń – {name}",
"reorderTitle": "Zmień kolejność",
@@ -3789,6 +3841,10 @@
"medications": {
"title": "Leki",
"description": "Widok listy i kolejność leków."
},
"mood": {
"title": "Tagi nastroju",
"description": "Grupy, tagi i kolejność w wyborze nastroju."
}
},
"profile": "Profil",
+33 -12
View File
@@ -26,6 +26,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { MedicationStatusPill } from "@/components/medications/card-parts/medication-status-pill";
import { useWeekdayLabel } from "@/components/medications/card-parts/medication-next-last-slot";
import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant";
import { estimateRunwayDays } from "@/components/medications/detail/supply-runway";
import { useMedicationIntake } from "@/components/medications/use-medication-intake";
import {
reduceCurrentWindowStatus,
@@ -70,6 +71,9 @@ interface TableSchedule {
daysOfWeek: string | null;
timesOfDay?: string[];
doseWindows?: { timeOfDay: string; start: string; end: string }[] | null;
/** Cadence fields the supply-runway estimate reads (v1.16.11). */
rrule?: string | null;
rollingIntervalDays?: number | null;
}
export interface TableMedication {
@@ -547,23 +551,40 @@ function MedicationTableRowItem({
);
const stock = medication.stockDosesRemaining;
// Projected days the stock covers — the same coarse estimate the
// detail Übersicht renders, so the column answers "do I need a
// refill" without opening the medication.
const runwayDays =
stock === null || stock === undefined
? null
: estimateRunwayDays(stock, medication.schedules);
const stockCell =
stock === null || stock === undefined ? (
<span className="text-muted-foreground">–</span>
) : (
<span
className={cn(
"tabular-nums",
stock === 0
? "text-destructive font-medium"
: stock < LOW_STOCK_DOSES
? "text-warning font-medium"
: undefined,
<span className="flex flex-col">
<span
className={cn(
"tabular-nums",
stock === 0
? "text-destructive font-medium"
: stock < LOW_STOCK_DOSES
? "text-warning font-medium"
: undefined,
)}
>
{stock === 1
? t("medications.tableStockDoseOne")
: t("medications.tableStockDoses", { count: stock })}
</span>
{runwayDays !== null && (
<span
className="text-muted-foreground text-xs"
data-slot="medication-table-runway"
>
{t("medications.tableStockRunway", { days: runwayDays })}
</span>
)}
>
{stock === 1
? t("medications.tableStockDoseOne")
: t("medications.tableStockDoses", { count: stock })}
</span>
);