feat(drink): add beverage tracking module with inline editing

New module for tracking all beverages (water, coffee, tea, juice, alcohol, etc.)
with daily progress bar, quick-tap presets, and inline editing of quantity/date/time.

Includes: module config, types, collections with guest seed (5 presets),
queries, store, ListView with context menus, route, app-registry registration,
Dexie schema v7, encryption registry, shared-branding icon/app entry.

Also extends docs/future/MODULE_IDEAS.md with additional module ideas.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-12 18:41:06 +02:00
parent 7314e9b763
commit d6a1c9fd8b
17 changed files with 1446 additions and 0 deletions

View file

@ -831,3 +831,13 @@ registerApp({
return first.id;
},
});
registerApp({
id: 'drink',
name: 'Drink',
color: '#3b82f6',
icon: Drop,
views: {
list: { load: () => import('$lib/modules/drink/ListView.svelte') },
},
});

View file

@ -403,6 +403,13 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
guides: { enabled: true, fields: ['title', 'description'] },
sections: { enabled: true, fields: ['title', 'content'] },
steps: { enabled: true, fields: ['title', 'content'] },
// ─── Drink ───────────────────────────────────────────────
// User-typed content (drink names, notes) → encrypted.
// Structural fields (date, time, drinkType, quantityMl, presetId) →
// plaintext for indexing and daily aggregation queries.
drinkEntries: { enabled: true, fields: ['name', 'note'] },
drinkPresets: { enabled: true, fields: ['name'] },
};
/**

View file

@ -380,6 +380,18 @@ db.version(6).stores({
firsts: 'id, status, category, date, priority, isPinned, isArchived',
});
// Schema version 7 — adds the Drink module (beverage tracking).
// Additive only; no prior tables touched.
//
// Index strategy:
// - drinkEntries indexes [date+time] for the daily timeline view
// (range scan on date, sorted by time within a day).
// - drinkPresets indexes `order` for the preset-picker sort.
db.version(7).stores({
drinkEntries: 'id, date, drinkType, presetId, [date+time]',
drinkPresets: 'id, order, drinkType, isArchived',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -88,6 +88,7 @@ import { whoModuleConfig } from '$lib/modules/who/module.config';
import { newsModuleConfig } from '$lib/modules/news/module.config';
import { bodyModuleConfig } from '$lib/modules/body/module.config';
import { firstsModuleConfig } from '$lib/modules/firsts/module.config';
import { drinkModuleConfig } from '$lib/modules/drink/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
manaCoreConfig,
@ -131,6 +132,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
newsModuleConfig,
bodyModuleConfig,
firstsModuleConfig,
drinkModuleConfig,
];
// ─── Derived Maps ──────────────────────────────────────────

View file

@ -28,6 +28,7 @@ import { TODO_GUEST_SEED } from '$lib/modules/todo/collections';
import { NOTES_GUEST_SEED } from '$lib/modules/notes/collections';
import { TIMES_GUEST_SEED } from '$lib/modules/times/collections';
import { PLANTA_GUEST_SEED } from '$lib/modules/planta/collections';
import { DRINK_GUEST_SEED } from '$lib/modules/drink/collections';
/**
* Flat list of { tableName, rows } entries. Only modules with non-empty
@ -60,6 +61,7 @@ register(TODO_GUEST_SEED);
register(NOTES_GUEST_SEED);
register(TIMES_GUEST_SEED);
register(PLANTA_GUEST_SEED);
register(DRINK_GUEST_SEED);
/**
* Seed all module guest data into empty tables. Idempotent: tables

View file

@ -0,0 +1,819 @@
<!--
Drink — ListView
Quick-tap presets, daily progress bar, and today's log.
-->
<script lang="ts">
import {
useAllDrinkEntries,
useAllDrinkPresets,
getActivePresets,
getEntriesForDate,
getTotalMlForDate,
todayStr,
formatMl,
} from './queries';
import { drinkStore } from './stores/drink.svelte';
import {
DRINK_TYPE_LABELS,
DRINK_TYPE_COLORS,
DEFAULT_DAILY_GOAL_ML,
type DrinkType,
} from './types';
import type { DrinkEntry, DrinkPreset } from './types';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import { DynamicIcon } from '@mana/shared-ui/atoms';
import { IconPicker } from '@mana/shared-ui/molecules';
import { Trash, Pause, Play } from '@mana/shared-icons';
let entries$ = useAllDrinkEntries();
let presets$ = useAllDrinkPresets();
let entries = $derived(entries$.value);
let presets = $derived(presets$.value);
let today = todayStr();
let activePresets = $derived(getActivePresets(presets));
let todayEntries = $derived(getEntriesForDate(entries, today));
let todayTotalMl = $derived(getTotalMlForDate(entries, today));
let goalProgress = $derived(Math.min(todayTotalMl / DEFAULT_DAILY_GOAL_ML, 1));
let goalReached = $derived(todayTotalMl >= DEFAULT_DAILY_GOAL_ML);
let animatingId = $state<string | null>(null);
let editingId = $state<string | null>(null);
let editQty = $state(0);
let editDate = $state('');
let editTime = $state('');
let showCreate = $state(false);
let newName = $state('');
let newDrinkType = $state<DrinkType>('water');
let newQuantityMl = $state(250);
let newIcon = $state('drop');
let newColor = $state('#3b82f6');
let showIconPicker = $state(false);
const QUICK_QUANTITIES = [150, 200, 250, 330, 500];
async function handlePresetTap(preset: DrinkPreset) {
await drinkStore.logFromPreset(preset.id);
animatingId = preset.id;
setTimeout(() => (animatingId = null), 300);
}
async function handleCreatePreset(e: Event) {
e.preventDefault();
if (!newName.trim()) return;
await drinkStore.createPreset({
name: newName.trim(),
icon: newIcon,
color: newColor,
drinkType: newDrinkType,
defaultQuantityMl: newQuantityMl,
});
newName = '';
newIcon = 'drop';
newColor = '#3b82f6';
newDrinkType = 'water';
newQuantityMl = 250;
showCreate = false;
showIconPicker = false;
}
const ctxMenuPreset = useItemContextMenu<DrinkPreset>();
let ctxMenuPresetItems = $derived<ContextMenuItem[]>(
ctxMenuPreset.state.target
? [
{
id: 'archive',
label: ctxMenuPreset.state.target.isArchived ? 'Aktivieren' : 'Archivieren',
icon: ctxMenuPreset.state.target.isArchived ? Play : Pause,
action: () => {
const target = ctxMenuPreset.state.target;
if (target) drinkStore.updatePreset(target.id, { isArchived: !target.isArchived });
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
const target = ctxMenuPreset.state.target;
if (target) drinkStore.deletePreset(target.id);
},
},
]
: []
);
const ctxMenuEntry = useItemContextMenu<DrinkEntry>();
let ctxMenuEntryItems = $derived<ContextMenuItem[]>(
ctxMenuEntry.state.target
? [
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
const target = ctxMenuEntry.state.target;
if (target) drinkStore.deleteEntry(target.id);
},
},
]
: []
);
function startEdit(entry: DrinkEntry) {
editingId = entry.id;
editQty = entry.quantityMl;
editDate = entry.date;
editTime = entry.time;
}
async function saveEdit() {
if (!editingId) return;
await drinkStore.updateEntry(editingId, {
quantityMl: editQty,
date: editDate,
time: editTime,
});
editingId = null;
}
function cancelEdit() {
editingId = null;
}
function handleEditKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
saveEdit();
}
if (e.key === 'Escape') {
cancelEdit();
}
}
function handleCreateKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleCreatePreset(e);
}
if (e.key === 'Escape') {
showCreate = false;
showIconPicker = false;
}
}
</script>
<div class="drink-view">
<!-- Daily Progress -->
<div class="progress-section">
<div class="progress-header">
<span class="progress-label">Heute</span>
<span class="progress-value" class:goal-reached={goalReached}>
{formatMl(todayTotalMl)}
<span class="progress-goal">/ {formatMl(DEFAULT_DAILY_GOAL_ML)}</span>
</span>
</div>
<div class="progress-bar">
<div
class="progress-fill"
class:goal-reached={goalReached}
style:width="{goalProgress * 100}%"
></div>
</div>
</div>
<!-- Preset Grid (quick-tap) -->
<div class="preset-grid">
{#each activePresets as preset (preset.id)}
<button
class="preset-item"
class:pulse={animatingId === preset.id}
onclick={() => handlePresetTap(preset)}
oncontextmenu={(e) => ctxMenuPreset.open(e, preset)}
>
<span class="preset-icon" style:color={preset.color}>
<DynamicIcon name={preset.icon} size={20} weight="bold" />
</span>
<span class="preset-qty">{formatMl(preset.defaultQuantityMl)}</span>
<span class="preset-name">{preset.name}</span>
</button>
{/each}
{#if !showCreate}
<button class="preset-item add-btn" onclick={() => (showCreate = true)}>
<span class="add-icon">+</span>
<span class="preset-name">Neu</span>
</button>
{/if}
</div>
<!-- Inline Create Form -->
{#if showCreate}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form class="create-form" onsubmit={handleCreatePreset} onkeydown={handleCreateKeydown}>
<div class="create-row">
<button
type="button"
class="icon-btn"
style:background={newColor}
onclick={() => (showIconPicker = !showIconPicker)}
>
<DynamicIcon name={newIcon} size={16} weight="bold" class="text-white" />
</button>
<!-- svelte-ignore a11y_autofocus -->
<input
class="create-input"
type="text"
placeholder="Name (z.B. Espresso)..."
bind:value={newName}
autofocus
/>
</div>
{#if showIconPicker}
<div class="icon-picker-wrapper">
<IconPicker
selectedIcon={newIcon}
onIconChange={(i) => {
newIcon = i;
showIconPicker = false;
}}
size="sm"
/>
</div>
{/if}
<div class="create-meta">
<select class="meta-select" bind:value={newDrinkType}>
{#each Object.entries(DRINK_TYPE_LABELS) as [key, label]}
<option value={key}>{label.de}</option>
{/each}
</select>
<div class="qty-row">
{#each QUICK_QUANTITIES as q}
<button
type="button"
class="qty-chip"
class:selected={newQuantityMl === q}
onclick={() => (newQuantityMl = q)}
>
{q} ml
</button>
{/each}
</div>
</div>
<div class="create-actions">
<button
type="button"
class="btn-cancel"
onclick={() => {
showCreate = false;
showIconPicker = false;
}}>Abbrechen</button
>
<button type="submit" class="btn-create" disabled={!newName.trim()}>Erstellen</button>
</div>
</form>
{/if}
<!-- Today's Log -->
{#if todayEntries.length > 0}
<div class="today-log">
<div class="log-label">Verlauf</div>
{#each todayEntries as entry (entry.id)}
{#if editingId === entry.id}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="log-row editing" onkeydown={handleEditKeydown}>
<span class="log-dot" style:background={DRINK_TYPE_COLORS[entry.drinkType]}></span>
<span class="log-name">{entry.name}</span>
<input class="edit-qty" type="number" min="1" step="10" bind:value={editQty} />
<span class="edit-unit">ml</span>
<input class="edit-date" type="date" bind:value={editDate} />
<input class="edit-time" type="time" bind:value={editTime} />
<button class="edit-save" onclick={saveEdit}>OK</button>
<button class="edit-cancel" onclick={cancelEdit}>×</button>
</div>
{:else}
<button
class="log-row"
onclick={() => startEdit(entry)}
oncontextmenu={(e) => ctxMenuEntry.open(e, entry)}
>
<span class="log-dot" style:background={DRINK_TYPE_COLORS[entry.drinkType]}></span>
<span class="log-name">{entry.name}</span>
<span class="log-qty">{formatMl(entry.quantityMl)}</span>
<span class="log-time">{entry.time}</span>
</button>
{/if}
{/each}
</div>
{/if}
<ContextMenu
visible={ctxMenuPreset.state.visible}
x={ctxMenuPreset.state.x}
y={ctxMenuPreset.state.y}
items={ctxMenuPresetItems}
onClose={ctxMenuPreset.close}
/>
<ContextMenu
visible={ctxMenuEntry.state.visible}
x={ctxMenuEntry.state.x}
y={ctxMenuEntry.state.y}
items={ctxMenuEntryItems}
onClose={ctxMenuEntry.close}
/>
{#if activePresets.length === 0 && !showCreate}
<div class="empty">
<p>Noch keine Getränke-Presets.</p>
<button class="empty-add-btn" onclick={() => (showCreate = true)}
>Erstes Getränk anlegen</button
>
</div>
{/if}
</div>
<style>
.drink-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem;
}
/* ── Progress ─────────────────────────────────── */
.progress-section {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.progress-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
}
.progress-value {
font-size: 1.125rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
}
.progress-value.goal-reached {
color: #22c55e;
}
.progress-goal {
font-size: 0.75rem;
font-weight: 400;
opacity: 0.6;
}
.progress-bar {
height: 6px;
border-radius: 3px;
background: hsl(var(--color-border));
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 3px;
background: #3b82f6;
transition: width 0.4s ease-out;
}
.progress-fill.goal-reached {
background: #22c55e;
}
/* ── Preset Grid ──────────────────────────────── */
.preset-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
}
.preset-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
padding: 0.5rem 0.25rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
cursor: pointer;
transition:
transform 0.15s,
box-shadow 0.15s;
user-select: none;
touch-action: manipulation;
color: hsl(var(--color-foreground));
}
.preset-item:hover {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.preset-item:active {
transform: scale(0.95);
}
.preset-item.pulse {
animation: tap-pulse 300ms ease-out;
}
.preset-icon {
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.preset-qty {
font-size: 0.6875rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-muted-foreground));
}
.preset-name {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
}
.add-btn {
border: 2px dashed hsl(var(--color-border));
background: transparent;
}
.add-btn:hover {
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary));
}
.add-icon {
font-size: 1.25rem;
font-weight: 300;
line-height: 1;
color: hsl(var(--color-muted-foreground));
}
.add-btn:hover .add-icon {
color: hsl(var(--color-primary));
}
/* ── Today's Log ──────────────────────────────── */
.today-log {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.log-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
padding: 0.25rem 0;
}
.log-row {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.375rem;
border-radius: 0.375rem;
font-size: 0.8125rem;
background: transparent;
border: none;
cursor: context-menu;
width: 100%;
text-align: left;
color: hsl(var(--color-foreground));
}
.log-row:hover {
background: hsl(var(--color-muted));
}
.log-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.log-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-qty {
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
font-size: 0.75rem;
flex-shrink: 0;
}
.log-time {
color: hsl(var(--color-muted-foreground));
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
/* ── Inline Edit ─────────────────────────────── */
.log-row.editing {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem;
border-radius: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-primary) / 0.3);
}
.edit-qty {
width: 3.5rem;
padding: 0.25rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
text-align: right;
}
.edit-qty:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.edit-unit {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
.edit-date,
.edit-time {
padding: 0.25rem 0.375rem;
border-radius: 0.25rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.75rem;
font-variant-numeric: tabular-nums;
}
.edit-date:focus,
.edit-time:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.edit-date {
width: 8rem;
}
.edit-time {
width: 5rem;
}
.edit-save {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: hsl(var(--color-primary));
color: white;
border: none;
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
flex-shrink: 0;
}
.edit-save:hover {
filter: brightness(1.1);
}
.edit-cancel {
padding: 0.25rem 0.375rem;
border-radius: 0.25rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
border: none;
font-size: 0.875rem;
cursor: pointer;
flex-shrink: 0;
line-height: 1;
}
.edit-cancel:hover {
color: hsl(var(--color-foreground));
}
/* ── Create Form ──────────────────────────────── */
.create-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 0.75rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.create-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-btn {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s;
}
.icon-btn:hover {
transform: scale(1.1);
}
.icon-picker-wrapper {
max-height: 14rem;
overflow-y: auto;
border-radius: 0.5rem;
padding: 0.5rem;
background: hsl(var(--color-muted));
border: 1px solid hsl(var(--color-border));
}
.create-input {
flex: 1;
background: transparent;
border: none;
border-bottom: 2px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
padding: 0.375rem 0;
outline: none;
}
.create-input:focus {
border-color: hsl(var(--color-primary));
}
.create-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.create-meta {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.meta-select {
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
background: hsl(var(--color-background));
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
}
.qty-row {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.qty-chip {
padding: 0.25rem 0.5rem;
border-radius: 1rem;
font-size: 0.6875rem;
font-weight: 500;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-background));
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition:
background 0.15s,
color 0.15s;
}
.qty-chip.selected {
background: hsl(var(--color-primary));
color: white;
border-color: hsl(var(--color-primary));
}
.qty-chip:hover:not(.selected) {
background: hsl(var(--color-muted));
}
.create-actions {
display: flex;
justify-content: flex-end;
gap: 0.375rem;
}
.btn-cancel,
.btn-create {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-cancel {
background: transparent;
color: hsl(var(--color-muted-foreground));
}
.btn-cancel:hover {
background: hsl(var(--color-muted));
}
.btn-create {
background: hsl(var(--color-primary));
color: white;
}
.btn-create:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-create:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Empty ────────────────────────────────────── */
.empty {
text-align: center;
color: hsl(var(--color-muted-foreground));
font-size: 0.875rem;
padding: 2rem 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.empty-add-btn {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary));
color: white;
border: none;
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: filter 0.15s;
}
.empty-add-btn:hover {
filter: brightness(1.1);
}
@keyframes tap-pulse {
0% {
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.4);
}
100% {
box-shadow: 0 0 0 12px rgba(59, 130, 246, 0);
}
}
@media (max-width: 640px) {
.drink-view {
padding: 0.5rem;
}
.preset-item {
padding: 0.625rem 0.375rem;
min-height: 44px;
}
}
</style>

View file

@ -0,0 +1,71 @@
/**
* Drink module collection accessors and guest seed data.
*
* Tables: drinkEntries, drinkPresets
*/
import { db } from '$lib/data/database';
import type { LocalDrinkEntry, LocalDrinkPreset } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const drinkEntryTable = db.table<LocalDrinkEntry>('drinkEntries');
export const drinkPresetTable = db.table<LocalDrinkPreset>('drinkPresets');
// ─── Guest Seed ────────────────────────────────────────────
export const DRINK_GUEST_SEED = {
drinkEntries: [] satisfies LocalDrinkEntry[],
drinkPresets: [
{
id: 'drink-preset-water',
name: 'Wasser',
icon: 'drop',
color: '#3b82f6',
drinkType: 'water',
defaultQuantityMl: 250,
order: 0,
isArchived: false,
},
{
id: 'drink-preset-coffee',
name: 'Kaffee',
icon: 'coffee',
color: '#92400e',
drinkType: 'coffee',
defaultQuantityMl: 200,
order: 1,
isArchived: false,
},
{
id: 'drink-preset-tea',
name: 'Tee',
icon: 'coffee',
color: '#65a30d',
drinkType: 'tea',
defaultQuantityMl: 250,
order: 2,
isArchived: false,
},
{
id: 'drink-preset-juice',
name: 'Saft',
icon: 'orange-slice',
color: '#f97316',
drinkType: 'juice',
defaultQuantityMl: 200,
order: 3,
isArchived: false,
},
{
id: 'drink-preset-beer',
name: 'Bier',
icon: 'beer-stein',
color: '#f59e0b',
drinkType: 'beer',
defaultQuantityMl: 330,
order: 4,
isArchived: false,
},
] satisfies LocalDrinkPreset[],
};

View file

@ -0,0 +1,40 @@
/**
* Drink module barrel exports.
*/
// ─── Stores ──────────────────────────────────────────────
export { drinkStore } from './stores/drink.svelte';
// ─── Queries ─────────────────────────────────────────────
export {
useAllDrinkEntries,
useAllDrinkPresets,
toDrinkEntry,
toDrinkPreset,
todayStr,
nowTime,
getEntriesForDate,
getTotalMlForDate,
getTotalMlByType,
groupEntriesByDate,
getActivePresets,
formatMl,
} from './queries';
// ─── Collections ─────────────────────────────────────────
export { drinkEntryTable, drinkPresetTable, DRINK_GUEST_SEED } from './collections';
// ─── Types ───────────────────────────────────────────────
export {
DRINK_TYPE_LABELS,
DRINK_TYPE_ICONS,
DRINK_TYPE_COLORS,
DEFAULT_DAILY_GOAL_ML,
} from './types';
export type {
LocalDrinkEntry,
LocalDrinkPreset,
DrinkEntry,
DrinkPreset,
DrinkType,
} from './types';

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const drinkModuleConfig: ModuleConfig = {
appId: 'drink',
tables: [{ name: 'drinkEntries' }, { name: 'drinkPresets' }],
};

View file

@ -0,0 +1,125 @@
/**
* Reactive Queries & Pure Helpers for Drink module.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { decryptRecords } from '$lib/data/crypto';
import { db } from '$lib/data/database';
import type { LocalDrinkEntry, LocalDrinkPreset, DrinkEntry, DrinkPreset } from './types';
// ─── Type Converters ──────────────────────────────────────
export function toDrinkEntry(local: LocalDrinkEntry): DrinkEntry {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
drinkType: local.drinkType,
quantityMl: local.quantityMl,
date: local.date,
time: local.time,
note: local.note ?? null,
presetId: local.presetId ?? null,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
export function toDrinkPreset(local: LocalDrinkPreset): DrinkPreset {
const now = new Date().toISOString();
return {
id: local.id,
name: local.name,
icon: local.icon,
color: local.color,
drinkType: local.drinkType,
defaultQuantityMl: local.defaultQuantityMl,
order: local.order,
isArchived: local.isArchived,
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
// ─── Live Queries ─────────────────────────────────────────
export function useAllDrinkEntries() {
return useLiveQueryWithDefault(async () => {
const locals = await db
.table<LocalDrinkEntry>('drinkEntries')
.orderBy('date')
.reverse()
.toArray();
const visible = locals.filter((e) => !e.deletedAt);
const decrypted = await decryptRecords('drinkEntries', visible);
return decrypted.map(toDrinkEntry);
}, [] as DrinkEntry[]);
}
export function useAllDrinkPresets() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalDrinkPreset>('drinkPresets').orderBy('order').toArray();
const visible = locals.filter((p) => !p.deletedAt);
const decrypted = await decryptRecords('drinkPresets', visible);
return decrypted.map(toDrinkPreset);
}, [] as DrinkPreset[]);
}
// ─── Pure Helpers ─────────────────────────────────────────
/** Get today's date string (YYYY-MM-DD) */
export function todayStr(): string {
return new Date().toISOString().split('T')[0];
}
/** Current time as HH:mm */
export function nowTime(): string {
const d = new Date();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/** Filter entries for a specific date */
export function getEntriesForDate(entries: DrinkEntry[], date: string): DrinkEntry[] {
return entries.filter((e) => e.date === date).sort((a, b) => b.time.localeCompare(a.time));
}
/** Total ml for a given date */
export function getTotalMlForDate(entries: DrinkEntry[], date: string): number {
return entries.filter((e) => e.date === date).reduce((sum, e) => sum + e.quantityMl, 0);
}
/** Total ml for a given date and drink type */
export function getTotalMlByType(entries: DrinkEntry[], date: string, drinkType: string): number {
return entries
.filter((e) => e.date === date && e.drinkType === drinkType)
.reduce((sum, e) => sum + e.quantityMl, 0);
}
/** Group entries by date (most recent first) */
export function groupEntriesByDate(entries: DrinkEntry[]): Map<string, DrinkEntry[]> {
const groups = new Map<string, DrinkEntry[]>();
for (const entry of entries) {
const existing = groups.get(entry.date) || [];
existing.push(entry);
groups.set(entry.date, existing);
}
// Sort entries within each group by time descending
for (const [, group] of groups) {
group.sort((a, b) => b.time.localeCompare(a.time));
}
return groups;
}
/** Get active (non-archived) presets sorted by order */
export function getActivePresets(presets: DrinkPreset[]): DrinkPreset[] {
return presets.filter((p) => !p.isArchived).sort((a, b) => a.order - b.order);
}
/** Format ml as a readable string */
export function formatMl(ml: number): string {
if (ml >= 1000) {
const liters = ml / 1000;
return `${liters % 1 === 0 ? liters.toFixed(0) : liters.toFixed(1)} L`;
}
return `${ml} ml`;
}

View file

@ -0,0 +1,147 @@
/**
* Drink Store Mutation-Only Service
*
* All reads are handled by liveQuery hooks in queries.ts.
*/
import { encryptRecord } from '$lib/data/crypto';
import { drinkEntryTable, drinkPresetTable } from '../collections';
import { toDrinkEntry, toDrinkPreset, todayStr, nowTime } from '../queries';
import type { LocalDrinkEntry, LocalDrinkPreset, DrinkType } from '../types';
export const drinkStore = {
// ─── Entries ──────────────────────────────────────────────
async logDrink(input: {
name: string;
drinkType: DrinkType;
quantityMl: number;
date?: string;
time?: string;
note?: string | null;
presetId?: string | null;
}) {
const newLocal: LocalDrinkEntry = {
id: crypto.randomUUID(),
name: input.name,
drinkType: input.drinkType,
quantityMl: input.quantityMl,
date: input.date ?? todayStr(),
time: input.time ?? nowTime(),
note: input.note ?? null,
presetId: input.presetId ?? null,
};
const snapshot = toDrinkEntry({ ...newLocal });
await encryptRecord('drinkEntries', newLocal);
await drinkEntryTable.add(newLocal);
return snapshot;
},
/** Quick-log from a preset (one tap) */
async logFromPreset(presetId: string) {
const preset = await drinkPresetTable.get(presetId);
if (!preset) return null;
return this.logDrink({
name: preset.name,
drinkType: preset.drinkType,
quantityMl: preset.defaultQuantityMl,
presetId: preset.id,
});
},
async updateEntry(
id: string,
patch: Partial<
Pick<LocalDrinkEntry, 'name' | 'quantityMl' | 'drinkType' | 'note' | 'time' | 'date'>
>
) {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('drinkEntries', wrapped);
await drinkEntryTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deleteEntry(id: string) {
await drinkEntryTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async undoLastEntry() {
const all = await drinkEntryTable.toArray();
const active = all
.filter((e) => !e.deletedAt)
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
if (active.length > 0) {
await drinkEntryTable.update(active[0].id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
}
},
// ─── Presets ──────────────────────────────────────────────
async createPreset(input: {
name: string;
icon: string;
color: string;
drinkType: DrinkType;
defaultQuantityMl: number;
}) {
const existing = await drinkPresetTable.toArray();
const count = existing.filter((p) => !p.deletedAt).length;
const newLocal: LocalDrinkPreset = {
id: crypto.randomUUID(),
name: input.name,
icon: input.icon,
color: input.color,
drinkType: input.drinkType,
defaultQuantityMl: input.defaultQuantityMl,
order: count,
isArchived: false,
};
const snapshot = toDrinkPreset({ ...newLocal });
await encryptRecord('drinkPresets', newLocal);
await drinkPresetTable.add(newLocal);
return snapshot;
},
async updatePreset(
id: string,
patch: Partial<
Pick<
LocalDrinkPreset,
'name' | 'icon' | 'color' | 'drinkType' | 'defaultQuantityMl' | 'isArchived' | 'order'
>
>
) {
const wrapped = { ...patch } as Record<string, unknown>;
await encryptRecord('drinkPresets', wrapped);
await drinkPresetTable.update(id, {
...wrapped,
updatedAt: new Date().toISOString(),
});
},
async deletePreset(id: string) {
await drinkPresetTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async reorderPresets(presetIds: string[]) {
const now = new Date().toISOString();
for (let i = 0; i < presetIds.length; i++) {
await drinkPresetTable.update(presetIds[i], {
order: i,
updatedAt: now,
});
}
},
};

View file

@ -0,0 +1,128 @@
/**
* Drink module types.
*
* DrinkEntry = a single logged drink with quantity and type
* DrinkPreset = a saved favourite drink for quick-add
*/
import type { BaseRecord } from '@mana/local-store';
// ─── Drink Types ─────────────────────────────────────────
export type DrinkType =
| 'water'
| 'coffee'
| 'tea'
| 'juice'
| 'soda'
| 'smoothie'
| 'milk'
| 'beer'
| 'wine'
| 'cocktail'
| 'spirit'
| 'energy'
| 'other';
// ─── Local Record Types (Dexie) ──────────────────────────
export interface LocalDrinkEntry extends BaseRecord {
name: string;
drinkType: DrinkType;
quantityMl: number;
date: string; // YYYY-MM-DD
time: string; // HH:mm
note: string | null;
presetId: string | null;
}
export interface LocalDrinkPreset extends BaseRecord {
name: string;
icon: string;
color: string;
drinkType: DrinkType;
defaultQuantityMl: number;
order: number;
isArchived: boolean;
}
// ─── Domain Types ────────────────────────────────────────
export interface DrinkEntry {
id: string;
name: string;
drinkType: DrinkType;
quantityMl: number;
date: string;
time: string;
note: string | null;
presetId: string | null;
createdAt: string;
updatedAt: string;
}
export interface DrinkPreset {
id: string;
name: string;
icon: string;
color: string;
drinkType: DrinkType;
defaultQuantityMl: number;
order: number;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
// ─── Constants ───────────────────────────────────────────
export const DRINK_TYPE_LABELS: Record<DrinkType, { de: string; en: string }> = {
water: { de: 'Wasser', en: 'Water' },
coffee: { de: 'Kaffee', en: 'Coffee' },
tea: { de: 'Tee', en: 'Tea' },
juice: { de: 'Saft', en: 'Juice' },
soda: { de: 'Limonade', en: 'Soda' },
smoothie: { de: 'Smoothie', en: 'Smoothie' },
milk: { de: 'Milch', en: 'Milk' },
beer: { de: 'Bier', en: 'Beer' },
wine: { de: 'Wein', en: 'Wine' },
cocktail: { de: 'Cocktail', en: 'Cocktail' },
spirit: { de: 'Spirituose', en: 'Spirit' },
energy: { de: 'Energy Drink', en: 'Energy Drink' },
other: { de: 'Sonstiges', en: 'Other' },
};
export const DRINK_TYPE_ICONS: Record<DrinkType, string> = {
water: 'drop',
coffee: 'coffee',
tea: 'coffee', // Phosphor doesn't have a teacup, use coffee
juice: 'orange-slice',
soda: 'beer-bottle',
smoothie: 'blender',
milk: 'cow',
beer: 'beer-stein',
wine: 'wine',
cocktail: 'martini',
spirit: 'martini',
energy: 'lightning',
other: 'drop',
};
export const DRINK_TYPE_COLORS: Record<DrinkType, string> = {
water: '#3b82f6',
coffee: '#92400e',
tea: '#65a30d',
juice: '#f97316',
soda: '#ef4444',
smoothie: '#a855f7',
milk: '#e5e7eb',
beer: '#f59e0b',
wine: '#881337',
cocktail: '#ec4899',
spirit: '#6366f1',
energy: '#22d3ee',
other: '#6b7280',
};
/** Default daily goal in ml */
export const DEFAULT_DAILY_GOAL_ML = 2500;

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllDrinkEntries, useAllDrinkPresets } from '$lib/modules/drink/queries';
let { children }: { children: Snippet } = $props();
const allEntries = useAllDrinkEntries();
const allPresets = useAllDrinkPresets();
setContext('drinkEntries', allEntries);
setContext('drinkPresets', allPresets);
</script>
{@render children()}

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/drink/ListView.svelte';
</script>
<svelte:head>
<title>Drink - Mana</title>
</svelte:head>
<ListView />

View file

@ -106,6 +106,37 @@ recommendation.
- **pets** — Vet appointments, vaccinations, feeding, weight
- **plants-care** — Extension of `planta`: watering plan, fertilizing, repotting
## Health & Body (additional)
- **drink** — ✅ **Built.** Getränke-Tracker für alle Getränke (Wasser, Kaffee, Tee, Saft, Alkohol etc.). Tages-/Wochenziele, Favoriten, Verlauf. Verknüpfung mit `nutriphi` und `body`.
- **breathe** — Atemübungen & Meditation-Timer mit geführten Mustern (Box Breathing, 4-7-8). Sessions-Log verknüpft mit `moodlit`.
- **fasting** — Intervallfasten-Timer (16:8, 5:2 etc.), verknüpft mit `nutriphi` und `body`.
## Knowledge & Productivity (additional)
- **readlog** — Lese-Fortschritt tracken (Seiten/Tag, aktuelles Kapitel). Leichter als `library`, fokussiert auf Dranbleiben.
- **snippets** — Code-Schnipsel-Bibliothek mit Syntax-Highlighting, Tags, Suche.
- **flashbacks** — "On this day"-Aggregator über alle Module. Journal, Fotos, Moods, Todos von vor 1/2/5 Jahren.
- **teach** — Feynman-Methode: Konzepte in eigenen Worten erklären, Lücken erkennen. Verknüpft mit `cards` und `skilltree`.
## Alltag & Organisation (additional)
- **routines** — Morgen-/Abend-Routinen als geordnete Checklisten mit Timer pro Schritt. Ergänzt `habits`.
- **documents** *(ZK)* — Persönliches Dokumenten-Management: Pass, Ausweis, Versicherungen, Verträge. Ablaufdaten mit Erinnerungen.
- **addresses** — Adressen-Sammlung über `contacts` hinaus: Ärzte, Handwerker, Restaurants mit Bewertungen.
## Social & Fun (additional)
- **challenges** — Gemeinsame Challenges mit Freunden (30 Tage Sport, Bücher lesen). Leaderboard, Beweise per Foto.
- **mixtapes** — Kuratierte Playlists/Musikempfehlungen für Freunde, verknüpft mit `music`.
## Meta & System
- **dashboard** — Konfigurierbares Dashboard mit Widgets aus allen Modulen. Tages-Überblick: Wetter, Termine, Habits, Mood, Todos.
- **review** — Wöchentliche/monatliche/jährliche Reviews: automatisch Daten aus allen Modulen aggregieren, Trends zeigen, Reflexionsfragen.
- **export** — Daten-Export pro Modul (JSON, CSV, PDF). DSGVO-konform, "deine Daten gehören dir".
- **integrations** — Webhook/API-Anbindungen für externe Dienste (Spotify, Strava, Toggl). Feeds Daten in bestehende Module.
---
## Next steps

View file

@ -161,6 +161,11 @@ export const APP_ICONS = {
// Warm amber→rose gradient to evoke excitement and novelty.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="fi" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#e11d48"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#fi)"/><path d="M50 18l5 14 14-5-10 11 10 11-14-5-5 14-5-14-14 5 10-11-10-11 14 5z" fill="white"/><circle cx="28" cy="70" r="4" fill="white" fill-opacity="0.6"/><circle cx="72" cy="68" r="3" fill="white" fill-opacity="0.5"/><circle cx="38" cy="80" r="2.5" fill="white" fill-opacity="0.4"/><circle cx="65" cy="82" r="2" fill="white" fill-opacity="0.35"/></svg>`
),
drink: svgToDataUrl(
// Water drop + glass — represents beverage tracking.
// Blue→cyan gradient for the hydration theme.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="dk" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#3b82f6"/><stop offset="100%" style="stop-color:#06b6d4"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#dk)"/><path d="M35 28h30l-4 48a10 10 0 0 1-10 9h-2a10 10 0 0 1-10-9L35 28z" fill="white" fill-opacity="0.9"/><path d="M39 52c0-4 5-6 11-6s11 2 11 6v12a8 8 0 0 1-8 7h-6a8 8 0 0 1-8-7V52z" fill="#3b82f6" fill-opacity="0.35"/><path d="M33 28h34" stroke="white" stroke-width="4" stroke-linecap="round"/><circle cx="72" cy="36" r="3" fill="white" fill-opacity="0.6"/><circle cx="68" cy="46" r="2" fill="white" fill-opacity="0.4"/><circle cx="74" cy="54" r="2.5" fill="white" fill-opacity="0.3"/></svg>`
),
who: svgToDataUrl(
// Theatre mask silhouette in front of a question mark — references
// the "guess who's behind the disguise" mechanic. Purple gradient.

View file

@ -751,6 +751,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'beta',
requiredTier: 'guest',
},
{
id: 'drink',
name: 'Drink',
description: {
de: 'Getränke-Tracker',
en: 'Beverage Tracker',
},
longDescription: {
de: 'Tracke alle Getränke — Wasser, Kaffee, Tee, Saft, Alkohol und mehr. Mit Tageszielen, Favoriten und Verlauf.',
en: 'Track all beverages — water, coffee, tea, juice, alcohol, and more. With daily goals, favourites, and history.',
},
icon: APP_ICONS.drink,
color: '#3b82f6',
comingSoon: false,
status: 'development',
requiredTier: 'guest',
},
{
id: 'who',
name: 'Who',