mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
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:
parent
7314e9b763
commit
d6a1c9fd8b
17 changed files with 1446 additions and 0 deletions
|
|
@ -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') },
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
819
apps/mana/apps/web/src/lib/modules/drink/ListView.svelte
Normal file
819
apps/mana/apps/web/src/lib/modules/drink/ListView.svelte
Normal 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>
|
||||
71
apps/mana/apps/web/src/lib/modules/drink/collections.ts
Normal file
71
apps/mana/apps/web/src/lib/modules/drink/collections.ts
Normal 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[],
|
||||
};
|
||||
40
apps/mana/apps/web/src/lib/modules/drink/index.ts
Normal file
40
apps/mana/apps/web/src/lib/modules/drink/index.ts
Normal 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';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const drinkModuleConfig: ModuleConfig = {
|
||||
appId: 'drink',
|
||||
tables: [{ name: 'drinkEntries' }, { name: 'drinkPresets' }],
|
||||
};
|
||||
125
apps/mana/apps/web/src/lib/modules/drink/queries.ts
Normal file
125
apps/mana/apps/web/src/lib/modules/drink/queries.ts
Normal 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`;
|
||||
}
|
||||
147
apps/mana/apps/web/src/lib/modules/drink/stores/drink.svelte.ts
Normal file
147
apps/mana/apps/web/src/lib/modules/drink/stores/drink.svelte.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
128
apps/mana/apps/web/src/lib/modules/drink/types.ts
Normal file
128
apps/mana/apps/web/src/lib/modules/drink/types.ts
Normal 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;
|
||||
15
apps/mana/apps/web/src/routes/(app)/drink/+layout.svelte
Normal file
15
apps/mana/apps/web/src/routes/(app)/drink/+layout.svelte
Normal 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()}
|
||||
9
apps/mana/apps/web/src/routes/(app)/drink/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/drink/+page.svelte
Normal 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 />
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue