feat(manacore/web): add habits module with tally board, inline create, and detail view

New habit tracking module: define habits (emoji, color, daily target), tap to log with timestamp, view streaks and 7-day charts. Includes workbench ListView with inline creation, full-page detail view, and drag/drop entity integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-03 13:34:07 +02:00
parent 0af8c7cec6
commit 5828f60934
21 changed files with 2455 additions and 1 deletions

View file

@ -53,6 +53,12 @@ export const APP_REGISTRY: AppEntry[] = [
detail: { load: () => import('$lib/modules/contacts/views/DetailView.svelte') },
},
},
{
id: 'habits',
name: 'Habits',
color: '#8B5CF6',
load: () => import('$lib/modules/habits/ListView.svelte'),
},
{
id: 'chat',
name: 'Chat',

View file

@ -175,6 +175,10 @@ db.version(1).stores({
// ─── Playground (appId: 'playground') ───
// No persistent data — stateless LLM playground
// ─── Habits (appId: 'habits') ───
habits: 'id, order, isArchived, color',
habitLogs: 'id, habitId, timestamp, [habitId+timestamp]',
// ─── Shared: Global Tags (appId: 'tags') ───
globalTags: 'id, name, groupId',
tagGroups: 'id',
@ -223,6 +227,7 @@ export const SYNC_APP_MAP: Record<string, string[]> = {
moodlit: ['moods', 'sequences', 'moodTags'],
memoro: ['memos', 'memories', 'memoTags', 'memoroSpaces', 'spaceMembers', 'memoSpaces'],
guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs', 'guideTags'],
habits: ['habits', 'habitLogs'],
tags: ['globalTags', 'tagGroups'],
links: ['manaLinks'],
};

View file

@ -0,0 +1,543 @@
<!--
Habits — Workbench ListView
Compact tally view with tap-to-log and today's counts.
-->
<script lang="ts">
import {
useAllHabits,
useAllHabitLogs,
getActiveHabits,
getTodayCounts,
todayStr,
formatTime,
} from './queries';
import { habitsStore } from './stores/habits.svelte';
import type { Habit, HabitLog } from './types';
import type { ViewProps } from '$lib/components/workbench/nav-stack';
let { navigate, goBack, params }: ViewProps = $props();
let habits$ = useAllHabits();
let logs$ = useAllHabitLogs();
let habits = $state<Habit[]>([]);
let logs = $state<HabitLog[]>([]);
$effect(() => {
const sub = habits$.subscribe((val) => {
habits = val ?? [];
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = logs$.subscribe((val) => {
logs = val ?? [];
});
return () => sub.unsubscribe();
});
let activeHabits = $derived(getActiveHabits(habits));
let todayCounts = $derived(getTodayCounts(habits, logs));
let todayLogs = $derived(
logs
.filter((l) => l.timestamp.startsWith(todayStr()))
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
.slice(0, 10)
);
let habitMap = $derived(new Map(habits.map((h) => [h.id, h])));
let animatingId = $state<string | null>(null);
let showCreate = $state(false);
let newTitle = $state('');
let newEmoji = $state('\u2b50');
let newColor = $state('#8b5cf6');
let showEmojiPicker = $state(false);
const QUICK_EMOJIS = [
'\u2615',
'\ud83d\udca7',
'\ud83d\udcaa',
'\ud83e\uddd8',
'\ud83c\udfc3',
'\ud83d\udcda',
'\ud83c\udf4e',
'\ud83d\udc8a',
'\ud83c\udf7a',
'\ud83d\udecc',
'\ud83c\udfb5',
'\ud83d\udeb4',
'\ud83d\udcdd',
'\ud83d\ude2e\u200d\ud83d\udca8',
'\ud83e\uddfc',
'\u2b50',
];
const QUICK_COLORS = [
'#ef4444',
'#f97316',
'#f59e0b',
'#22c55e',
'#06b6d4',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#d946ef',
'#ec4899',
];
async function handleTap(habitId: string) {
await habitsStore.logHabit(habitId);
animatingId = habitId;
setTimeout(() => (animatingId = null), 300);
}
async function handleCreate(e: Event) {
e.preventDefault();
if (!newTitle.trim()) return;
await habitsStore.createHabit({
title: newTitle.trim(),
emoji: newEmoji,
color: newColor,
});
newTitle = '';
newEmoji = '\u2b50';
showCreate = false;
showEmojiPicker = false;
}
function handleCreateKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleCreate(e);
}
if (e.key === 'Escape') {
showCreate = false;
showEmojiPicker = false;
}
}
</script>
<div class="habits-list-view">
<!-- Tally Grid -->
<div class="tally-grid">
{#each activeHabits as habit (habit.id)}
{@const count = todayCounts.get(habit.id) ?? 0}
{@const overTarget = habit.targetPerDay !== null && count >= habit.targetPerDay}
<button
class="tally-item"
class:over-target={overTarget}
class:pulse={animatingId === habit.id}
onclick={() => handleTap(habit.id)}
>
<span class="tally-emoji">{habit.emoji}</span>
<span class="tally-count" style:color={habit.color}>
{count}{#if habit.targetPerDay}<span class="tally-target">/{habit.targetPerDay}</span
>{/if}
</span>
<span class="tally-name">{habit.title}</span>
</button>
{/each}
<!-- Add button in grid -->
{#if !showCreate}
<button class="tally-item add-btn" onclick={() => (showCreate = true)}>
<span class="add-icon">+</span>
<span class="tally-name">Neu</span>
</button>
{/if}
</div>
<!-- Inline Create Form -->
{#if showCreate}
<form class="create-form" onsubmit={handleCreate} onkeydown={handleCreateKeydown}>
<div class="create-row">
<button
type="button"
class="emoji-btn"
style:background={newColor}
onclick={() => (showEmojiPicker = !showEmojiPicker)}
>
{newEmoji}
</button>
<input
class="create-input"
type="text"
placeholder="Habit Name..."
bind:value={newTitle}
autofocus
/>
</div>
{#if showEmojiPicker}
<div class="emoji-row">
{#each QUICK_EMOJIS as e}
<button
type="button"
class="emoji-opt"
class:selected={newEmoji === e}
onclick={() => {
newEmoji = e;
showEmojiPicker = false;
}}>{e}</button
>
{/each}
</div>
{/if}
<div class="color-row">
{#each QUICK_COLORS as c}
<button
type="button"
class="color-dot"
class:selected={newColor === c}
style:background={c}
onclick={() => (newColor = c)}
></button>
{/each}
</div>
<div class="create-actions">
<button
type="button"
class="btn-cancel"
onclick={() => {
showCreate = false;
showEmojiPicker = false;
}}>Abbrechen</button
>
<button type="submit" class="btn-create" disabled={!newTitle.trim()}>Erstellen</button>
</div>
</form>
{/if}
<!-- Recent Logs -->
{#if todayLogs.length > 0}
<div class="recent-logs">
<div class="recent-label">Heute</div>
{#each todayLogs as log (log.id)}
{@const habit = habitMap.get(log.habitId)}
{#if habit}
<div class="log-row">
<span class="log-emoji">{habit.emoji}</span>
<span class="log-name">{habit.title}</span>
<span class="log-time">{formatTime(log.timestamp)}</span>
</div>
{/if}
{/each}
</div>
{/if}
{#if activeHabits.length === 0 && !showCreate}
<div class="empty">
<p>Noch keine Habits angelegt.</p>
<button class="empty-add-btn" onclick={() => (showCreate = true)}
>Erstes Habit erstellen</button
>
</div>
{/if}
</div>
<style>
.habits-list-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.5rem;
}
.tally-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
}
.tally-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
padding: 0.5rem 0.25rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
cursor: pointer;
transition:
transform 0.15s,
box-shadow 0.15s;
user-select: none;
touch-action: manipulation;
}
.tally-item:hover {
transform: scale(1.05);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.tally-item:active {
transform: scale(0.95);
}
.tally-item.pulse {
animation: tap-pulse 300ms ease-out;
}
.tally-item.over-target {
border-color: rgba(34, 197, 94, 0.3);
background: rgba(34, 197, 94, 0.06);
}
.tally-emoji {
font-size: 1.25rem;
line-height: 1;
}
.tally-count {
font-size: 1.125rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.tally-target {
font-size: 0.6875rem;
font-weight: 400;
opacity: 0.6;
}
.tally-name {
font-size: 0.625rem;
color: var(--color-muted-foreground);
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 100%;
}
.recent-logs {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.recent-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: 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;
}
.log-emoji {
font-size: 0.875rem;
flex-shrink: 0;
}
.log-name {
color: var(--color-foreground);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.log-time {
color: var(--color-muted-foreground);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
}
.add-btn {
border: 2px dashed var(--color-border, rgba(255, 255, 255, 0.15));
background: transparent;
}
.add-btn:hover {
border-color: var(--color-primary, #6366f1);
color: var(--color-primary, #6366f1);
}
.add-icon {
font-size: 1.25rem;
font-weight: 300;
line-height: 1;
color: var(--color-muted-foreground);
}
.add-btn:hover .add-icon {
color: var(--color-primary, #6366f1);
}
/* ── Create Form ──────────────────────────────── */
.create-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.06));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
}
.create-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.emoji-btn {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
border: none;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s;
}
.emoji-btn:hover {
transform: scale(1.1);
}
.create-input {
flex: 1;
background: transparent;
border: none;
border-bottom: 2px solid var(--color-border, rgba(255, 255, 255, 0.15));
color: var(--color-foreground);
font-size: 0.875rem;
padding: 0.375rem 0;
outline: none;
}
.create-input:focus {
border-color: var(--color-primary, #6366f1);
}
.create-input::placeholder {
color: var(--color-muted-foreground);
}
.emoji-row {
display: flex;
flex-wrap: wrap;
gap: 0.125rem;
}
.emoji-opt {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.375rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
background: transparent;
border: 2px solid transparent;
cursor: pointer;
}
.emoji-opt:hover {
background: rgba(255, 255, 255, 0.08);
}
.emoji-opt.selected {
border-color: var(--color-primary, #6366f1);
}
.color-row {
display: flex;
gap: 0.25rem;
}
.color-dot {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s;
}
.color-dot:hover {
transform: scale(1.25);
}
.color-dot.selected {
border-color: white;
box-shadow: 0 0 0 1px var(--color-primary, #6366f1);
}
.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: var(--color-muted-foreground);
}
.btn-cancel:hover {
background: var(--color-muted, rgba(255, 255, 255, 0.08));
}
.btn-create {
background: var(--color-primary, #6366f1);
color: white;
}
.btn-create:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-create:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ── Empty / Misc ─────────────────────────────── */
.empty {
text-align: center;
color: 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: var(--color-primary, #6366f1);
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(139, 92, 246, 0.4);
}
100% {
box-shadow: 0 0 0 12px rgba(139, 92, 246, 0);
}
}
</style>

View file

@ -0,0 +1,49 @@
/**
* Habits module collection accessors and guest seed data.
*
* Tables are defined in the unified database.ts as:
* habits, habitLogs
*/
import { db } from '$lib/data/database';
import type { LocalHabit, LocalHabitLog } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const habitTable = db.table<LocalHabit>('habits');
export const habitLogTable = db.table<LocalHabitLog>('habitLogs');
// ─── Guest Seed ────────────────────────────────────────────
export const HABITS_GUEST_SEED = {
habits: [
{
id: 'habit-coffee',
title: 'Kaffee',
emoji: '\u2615',
color: '#f59e0b',
targetPerDay: 3,
order: 0,
isArchived: false,
},
{
id: 'habit-water',
title: 'Wasser',
emoji: '\ud83d\udca7',
color: '#06b6d4',
targetPerDay: 8,
order: 1,
isArchived: false,
},
{
id: 'habit-workout',
title: 'Workout',
emoji: '\ud83d\udcaa',
color: '#22c55e',
targetPerDay: 1,
order: 2,
isArchived: false,
},
] satisfies LocalHabit[],
habitLogs: [] satisfies LocalHabitLog[],
};

View file

@ -0,0 +1,122 @@
<!--
DayTimeline — shows all habit logs for a given date as a timeline.
-->
<script lang="ts">
import type { Habit, HabitLog } from '../types';
import { formatTime } from '../queries';
let {
logs,
habits,
date,
}: {
logs: HabitLog[];
habits: Habit[];
date: string;
} = $props();
let habitMap = $derived(new Map(habits.map((h) => [h.id, h])));
let dateLogs = $derived(
logs
.filter((l) => l.timestamp.startsWith(date))
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
);
function formatDateLabel(d: string): string {
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
if (d === today) return 'Heute';
if (d === yesterday) return 'Gestern';
return new Date(d).toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
}
</script>
{#if dateLogs.length > 0}
<div class="day-timeline">
<div class="day-label">{formatDateLabel(date)}</div>
<div class="timeline-entries">
{#each dateLogs as log (log.id)}
{@const habit = habitMap.get(log.habitId)}
{#if habit}
<div class="timeline-entry">
<div class="entry-dot" style:background={habit.color}></div>
<span class="entry-emoji">{habit.emoji}</span>
<span class="entry-title">{habit.title}</span>
<span class="entry-time">{formatTime(log.timestamp)}</span>
{#if log.note}
<span class="entry-note">{log.note}</span>
{/if}
</div>
{/if}
{/each}
</div>
</div>
{/if}
<style>
.day-timeline {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.day-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted-foreground);
padding: 0 0.25rem;
}
.timeline-entries {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.timeline-entry {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
font-size: 0.875rem;
}
.entry-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.entry-emoji {
font-size: 1rem;
flex-shrink: 0;
}
.entry-title {
color: var(--color-foreground);
font-weight: 500;
}
.entry-time {
margin-left: auto;
color: var(--color-muted-foreground);
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
}
.entry-note {
color: var(--color-muted-foreground);
font-size: 0.8125rem;
font-style: italic;
}
</style>

View file

@ -0,0 +1,103 @@
<!--
HabitBoard — the main tally grid view.
Shows all active habits as tappable tiles + inline create.
-->
<script lang="ts">
import type { Habit, HabitLog } from '../types';
import { getActiveHabits, getTodayCounts } from '../queries';
import HabitTile from './HabitTile.svelte';
import HabitForm from './HabitForm.svelte';
let {
habits,
logs,
onDetail,
}: {
habits: Habit[];
logs: HabitLog[];
onDetail: (habit: Habit) => void;
} = $props();
let showCreate = $state(false);
let activeHabits = $derived(getActiveHabits(habits));
let todayCounts = $derived(getTodayCounts(habits, logs));
</script>
<div class="habit-board">
<div class="habit-grid">
{#each activeHabits as habit (habit.id)}
<HabitTile {habit} todayCount={todayCounts.get(habit.id) ?? 0} {onDetail} />
{/each}
<!-- Add button -->
{#if !showCreate}
<button class="add-tile" onclick={() => (showCreate = true)}>
<span class="add-icon">+</span>
<span class="add-label">Neu</span>
</button>
{/if}
</div>
{#if showCreate}
<div class="create-form-wrapper">
<HabitForm onDone={() => (showCreate = false)} onCancel={() => (showCreate = false)} />
</div>
{/if}
</div>
<style>
.habit-board {
display: flex;
flex-direction: column;
gap: 1rem;
}
.habit-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 0.75rem;
}
@media (min-width: 640px) {
.habit-grid {
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
}
}
.add-tile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
aspect-ratio: 1;
border-radius: 1rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 2px dashed var(--color-border, rgba(255, 255, 255, 0.15));
color: var(--color-muted-foreground);
cursor: pointer;
transition: all 0.15s;
}
.add-tile:hover {
border-color: var(--color-primary, #6366f1);
color: var(--color-primary, #6366f1);
background: rgba(99, 102, 241, 0.06);
}
.add-icon {
font-size: 1.5rem;
font-weight: 300;
line-height: 1;
}
.add-label {
font-size: 0.75rem;
font-weight: 500;
}
.create-form-wrapper {
max-width: 400px;
}
</style>

View file

@ -0,0 +1,480 @@
<!--
HabitDetail — full detail view for a single habit.
Shows stats, log timeline, edit form, and archive/delete actions.
-->
<script lang="ts">
import type { Habit, HabitLog } from '../types';
import { habitsStore } from '../stores/habits.svelte';
import { getCountForDate, getStreak, groupLogsByDate, todayStr, formatTime } from '../queries';
import HabitForm from './HabitForm.svelte';
let {
habit,
logs,
onBack,
}: {
habit: Habit;
logs: HabitLog[];
onBack: () => void;
} = $props();
let showEdit = $state(false);
let confirmDelete = $state(false);
let todayCount = $derived(getCountForDate(logs, habit.id, todayStr()));
let streak = $derived(getStreak(logs, habit.id));
let totalLogs = $derived(logs.filter((l) => l.habitId === habit.id).length);
let habitLogs = $derived(
logs
.filter((l) => l.habitId === habit.id)
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
);
let groupedLogs = $derived(groupLogsByDate(habitLogs));
// Last 7 days for mini chart
let last7Days = $derived(() => {
const days: { date: string; count: number }[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date();
d.setDate(d.getDate() - i);
const dateStr = d.toISOString().split('T')[0];
days.push({
date: dateStr,
count: getCountForDate(logs, habit.id, dateStr),
});
}
return days;
});
let maxCount = $derived(Math.max(1, ...last7Days().map((d) => d.count)));
function formatDateLabel(d: string): string {
const today = todayStr();
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
if (d === today) return 'Heute';
if (d === yesterday) return 'Gestern';
return new Date(d).toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
});
}
function formatDayLabel(d: string): string {
return new Date(d).toLocaleDateString('de-DE', { weekday: 'narrow' });
}
async function handleDelete() {
await habitsStore.deleteHabit(habit.id);
onBack();
}
async function handleArchive() {
await habitsStore.updateHabit(habit.id, { isArchived: !habit.isArchived });
onBack();
}
async function handleDeleteLog(logId: string) {
await habitsStore.deleteLog(logId);
}
</script>
<div class="habit-detail">
<!-- Header -->
<header class="detail-header">
<button class="back-btn" onclick={onBack}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="header-info" style:--habit-color={habit.color}>
<span class="header-emoji">{habit.emoji}</span>
<h2 class="header-title">{habit.title}</h2>
</div>
<button class="edit-btn" onclick={() => (showEdit = !showEdit)}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
</svg>
</button>
</header>
<!-- Edit Form (inline) -->
{#if showEdit}
<HabitForm {habit} onDone={() => (showEdit = false)} onCancel={() => (showEdit = false)} />
{/if}
<!-- Stats Cards -->
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value">{todayCount}</span>
<span class="stat-label">Heute</span>
</div>
<div class="stat-card">
<span class="stat-value">{streak}</span>
<span class="stat-label">Streak</span>
</div>
<div class="stat-card">
<span class="stat-value">{totalLogs}</span>
<span class="stat-label">Gesamt</span>
</div>
</div>
<!-- 7-day chart -->
<div class="week-chart">
<div class="chart-title">Letzte 7 Tage</div>
<div class="chart-bars">
{#each last7Days() as day}
<div class="bar-column">
<div class="bar-wrapper">
<div
class="bar"
style:height="{(day.count / maxCount) * 100}%"
style:background={habit.color}
></div>
</div>
<span class="bar-count">{day.count}</span>
<span class="bar-day">{formatDayLabel(day.date)}</span>
</div>
{/each}
</div>
</div>
<!-- Log button -->
<button
class="log-btn"
style:background={habit.color}
onclick={() => habitsStore.logHabit(habit.id)}
>
{habit.emoji} Jetzt loggen
</button>
<!-- Timeline -->
<div class="log-timeline">
<h3 class="section-title">Verlauf</h3>
{#each [...groupedLogs.entries()] as [date, dayLogs] (date)}
<div class="day-group">
<div class="day-header">{formatDateLabel(date)}</div>
{#each dayLogs as log (log.id)}
<div class="log-entry">
<span class="log-time">{formatTime(log.timestamp)}</span>
{#if log.note}
<span class="log-note">{log.note}</span>
{/if}
<button class="log-delete" onclick={() => handleDeleteLog(log.id)} title="Entfernen">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
{/each}
</div>
{/each}
{#if habitLogs.length === 0}
<p class="empty-text">Noch keine Einträge. Tippe oben zum Loggen.</p>
{/if}
</div>
<!-- Actions -->
<div class="detail-actions">
<button class="action-btn" onclick={handleArchive}>
{habit.isArchived ? 'Wiederherstellen' : 'Archivieren'}
</button>
{#if !confirmDelete}
<button class="action-btn danger" onclick={() => (confirmDelete = true)}> Löschen </button>
{:else}
<button class="action-btn danger" onclick={handleDelete}> Wirklich löschen? </button>
{/if}
</div>
</div>
<style>
.habit-detail {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 480px;
}
.detail-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.back-btn,
.edit-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: transparent;
color: var(--color-muted-foreground);
border: none;
cursor: pointer;
transition: background 0.15s;
}
.back-btn:hover,
.edit-btn:hover {
background: var(--color-muted, rgba(255, 255, 255, 0.08));
}
.header-info {
flex: 1;
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-emoji {
font-size: 1.5rem;
}
.header-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--color-foreground);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
}
.stat-label {
font-size: 0.75rem;
color: var(--color-muted-foreground);
margin-top: 0.125rem;
}
.week-chart {
padding: 1rem;
border-radius: 0.75rem;
background: var(--color-surface, rgba(255, 255, 255, 0.04));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
}
.chart-title {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-muted-foreground);
margin-bottom: 0.75rem;
}
.chart-bars {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 0.5rem;
height: 80px;
}
.bar-column {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.bar-wrapper {
width: 100%;
height: 60px;
display: flex;
align-items: flex-end;
justify-content: center;
}
.bar {
width: 100%;
max-width: 28px;
min-height: 2px;
border-radius: 0.25rem 0.25rem 0 0;
transition: height 0.3s ease-out;
}
.bar-count {
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
}
.bar-day {
font-size: 0.625rem;
color: var(--color-muted-foreground);
text-transform: uppercase;
}
.log-btn {
width: 100%;
padding: 0.75rem;
border-radius: 0.75rem;
border: none;
color: white;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition:
filter 0.15s,
transform 0.15s;
}
.log-btn:hover {
filter: brightness(1.1);
}
.log-btn:active {
transform: scale(0.98);
}
.log-timeline {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-foreground);
}
.day-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.day-header {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-muted-foreground);
padding: 0.25rem 0;
}
.log-entry {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
background: var(--color-surface, rgba(255, 255, 255, 0.03));
}
.log-time {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-foreground);
font-variant-numeric: tabular-nums;
}
.log-note {
font-size: 0.8125rem;
color: var(--color-muted-foreground);
font-style: italic;
flex: 1;
}
.log-delete {
display: flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
border-radius: 0.25rem;
background: transparent;
color: var(--color-muted-foreground);
border: none;
cursor: pointer;
opacity: 0;
margin-left: auto;
transition: all 0.15s;
}
.log-entry:hover .log-delete {
opacity: 1;
}
.log-delete:hover {
color: var(--color-destructive, #ef4444);
background: rgba(239, 68, 68, 0.1);
}
.detail-actions {
display: flex;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, rgba(255, 255, 255, 0.08));
}
.action-btn {
padding: 0.5rem 0.75rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 500;
background: var(--color-surface, rgba(255, 255, 255, 0.06));
color: var(--color-muted-foreground);
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
cursor: pointer;
transition: all 0.15s;
}
.action-btn:hover {
background: var(--color-muted, rgba(255, 255, 255, 0.1));
}
.action-btn.danger {
color: var(--color-destructive, #ef4444);
border-color: rgba(239, 68, 68, 0.2);
}
.action-btn.danger:hover {
background: rgba(239, 68, 68, 0.1);
}
.empty-text {
color: var(--color-muted-foreground);
font-size: 0.875rem;
text-align: center;
padding: 1.5rem 0;
}
</style>

View file

@ -0,0 +1,291 @@
<!--
HabitForm — inline form for creating or editing a habit.
Used both on the main page and in detail view.
-->
<script lang="ts">
import { habitsStore } from '../stores/habits.svelte';
import { HABIT_COLORS, HABIT_EMOJIS, type Habit } from '../types';
let {
habit = null,
onDone,
onCancel,
}: {
habit?: Habit | null;
onDone: () => void;
onCancel: () => void;
} = $props();
let title = $state(habit?.title ?? '');
let emoji = $state(habit?.emoji ?? '\u2b50');
let color = $state(habit?.color ?? '#6366f1');
let targetPerDay = $state<string>(habit?.targetPerDay?.toString() ?? '');
let showEmojiPicker = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) return;
const target = targetPerDay.trim() ? parseInt(targetPerDay) : null;
if (habit) {
await habitsStore.updateHabit(habit.id, {
title: title.trim(),
emoji,
color,
targetPerDay: target,
});
} else {
await habitsStore.createHabit({
title: title.trim(),
emoji,
color,
targetPerDay: target,
});
}
onDone();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
if (e.key === 'Escape') {
onCancel();
}
}
</script>
<form class="habit-form" onsubmit={handleSubmit} onkeydown={handleKeydown}>
<div class="form-row">
<button
type="button"
class="emoji-btn"
style:background={color}
onclick={() => (showEmojiPicker = !showEmojiPicker)}
>
{emoji}
</button>
<input
class="title-input"
type="text"
placeholder="Habit Name..."
bind:value={title}
autofocus
/>
</div>
{#if showEmojiPicker}
<div class="emoji-picker">
{#each HABIT_EMOJIS as e}
<button
type="button"
class="emoji-option"
class:selected={emoji === e}
onclick={() => {
emoji = e;
showEmojiPicker = false;
}}
>
{e}
</button>
{/each}
</div>
{/if}
<div class="color-picker">
{#each HABIT_COLORS as c}
<button
type="button"
class="color-swatch"
class:selected={color === c}
style:background={c}
onclick={() => (color = c)}
></button>
{/each}
</div>
<div class="form-row">
<label class="target-label">
<span>Tagesziel</span>
<input
class="target-input"
type="number"
min="1"
max="100"
placeholder="-"
bind:value={targetPerDay}
/>
</label>
</div>
<div class="form-actions">
<button type="button" class="btn-cancel" onclick={onCancel}>Abbrechen</button>
<button type="submit" class="btn-save" disabled={!title.trim()}>
{habit ? 'Speichern' : 'Erstellen'}
</button>
</div>
</form>
<style>
.habit-form {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
border-radius: 1rem;
background: var(--color-surface, rgba(255, 255, 255, 0.06));
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.1));
}
.form-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.emoji-btn {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.75rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.25rem;
border: none;
cursor: pointer;
flex-shrink: 0;
transition: transform 0.15s;
}
.emoji-btn:hover {
transform: scale(1.1);
}
.title-input {
flex: 1;
background: transparent;
border: none;
border-bottom: 2px solid var(--color-border, rgba(255, 255, 255, 0.15));
color: var(--color-foreground);
font-size: 1rem;
padding: 0.5rem 0;
outline: none;
}
.title-input:focus {
border-color: var(--color-primary, #6366f1);
}
.title-input::placeholder {
color: var(--color-muted-foreground);
}
.emoji-picker {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.emoji-option {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.125rem;
background: transparent;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.15s;
}
.emoji-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.emoji-option.selected {
border-color: var(--color-primary, #6366f1);
background: rgba(99, 102, 241, 0.15);
}
.color-picker {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.color-swatch {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: transform 0.15s;
}
.color-swatch:hover {
transform: scale(1.2);
}
.color-swatch.selected {
border-color: white;
box-shadow: 0 0 0 2px var(--color-primary, #6366f1);
}
.target-label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-muted-foreground);
}
.target-input {
width: 4rem;
background: transparent;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.15));
border-radius: 0.5rem;
color: var(--color-foreground);
font-size: 0.875rem;
padding: 0.375rem 0.5rem;
outline: none;
text-align: center;
}
.target-input:focus {
border-color: var(--color-primary, #6366f1);
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.btn-cancel,
.btn-save {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
border: none;
transition: all 0.15s;
}
.btn-cancel {
background: transparent;
color: var(--color-muted-foreground);
}
.btn-cancel:hover {
background: var(--color-muted, rgba(255, 255, 255, 0.08));
}
.btn-save {
background: var(--color-primary, #6366f1);
color: white;
}
.btn-save:hover:not(:disabled) {
filter: brightness(1.1);
}
.btn-save:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,222 @@
<!--
HabitTile — single tappable tile in the tally board.
Shows emoji, title, today's count, and optional target.
Tap = log habit. Long press = undo last.
-->
<script lang="ts">
import type { Habit } from '../types';
import { habitsStore } from '../stores/habits.svelte';
let {
habit,
todayCount = 0,
onDetail,
}: {
habit: Habit;
todayCount: number;
onDetail: (habit: Habit) => void;
} = $props();
let pressing = $state(false);
let pressTimer: ReturnType<typeof setTimeout> | null = null;
let justLongPressed = $state(false);
let animatePulse = $state(false);
function handlePointerDown() {
pressing = true;
justLongPressed = false;
pressTimer = setTimeout(() => {
justLongPressed = true;
pressing = false;
habitsStore.undoLastLog(habit.id);
}, 500);
}
function handlePointerUp() {
pressing = false;
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
if (!justLongPressed) {
habitsStore.logHabit(habit.id);
animatePulse = true;
setTimeout(() => (animatePulse = false), 300);
}
}
function handlePointerLeave() {
pressing = false;
if (pressTimer) {
clearTimeout(pressTimer);
pressTimer = null;
}
}
let isOverTarget = $derived(habit.targetPerDay !== null && todayCount >= habit.targetPerDay);
let progressPercent = $derived(
habit.targetPerDay ? Math.min(100, (todayCount / habit.targetPerDay) * 100) : 0
);
</script>
<div class="habit-tile-wrapper">
<button
class="habit-tile"
class:pressing
class:pulse={animatePulse}
class:over-target={isOverTarget}
style:--habit-color={habit.color}
onpointerdown={handlePointerDown}
onpointerup={handlePointerUp}
onpointerleave={handlePointerLeave}
>
{#if habit.targetPerDay}
<div class="progress-ring" style:--progress="{progressPercent}%"></div>
{/if}
<span class="emoji">{habit.emoji}</span>
<span class="title">{habit.title}</span>
<span class="count">
{todayCount}{#if habit.targetPerDay}<span class="target">/{habit.targetPerDay}</span>{/if}
</span>
</button>
<button class="detail-btn" onclick={() => onDetail(habit)} title="Details">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div>
<style>
.habit-tile-wrapper {
position: relative;
}
.habit-tile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.25rem;
width: 100%;
aspect-ratio: 1;
border-radius: 1rem;
background: var(--habit-color);
background: linear-gradient(
135deg,
var(--habit-color),
color-mix(in srgb, var(--habit-color) 70%, black)
);
color: white;
cursor: pointer;
user-select: none;
touch-action: manipulation;
border: none;
overflow: hidden;
transition:
transform 0.15s ease,
box-shadow 0.15s ease;
}
.habit-tile:hover {
transform: scale(1.03);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.habit-tile.pressing {
transform: scale(0.95);
}
.habit-tile.pulse {
animation: tap-pulse 300ms ease-out;
}
.habit-tile.over-target {
box-shadow:
0 0 0 3px rgba(255, 255, 255, 0.3),
0 4px 16px rgba(0, 0, 0, 0.15);
}
.progress-ring {
position: absolute;
inset: 0;
border-radius: 1rem;
background: conic-gradient(
rgba(255, 255, 255, 0.2) var(--progress),
transparent var(--progress)
);
pointer-events: none;
}
.emoji {
font-size: 2rem;
line-height: 1;
position: relative;
z-index: 1;
}
.title {
font-size: 0.75rem;
font-weight: 600;
opacity: 0.9;
position: relative;
z-index: 1;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
max-width: 90%;
}
.count {
font-size: 1.25rem;
font-weight: 700;
position: relative;
z-index: 1;
}
.target {
font-size: 0.75rem;
font-weight: 400;
opacity: 0.7;
}
.detail-btn {
position: absolute;
top: 0.25rem;
right: 0.25rem;
width: 1.5rem;
height: 1.5rem;
border-radius: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.15);
color: white;
border: none;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
z-index: 2;
}
.habit-tile-wrapper:hover .detail-btn {
opacity: 1;
}
@keyframes tap-pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.5);
}
100% {
box-shadow: 0 0 0 20px rgba(255, 255, 255, 0);
}
}
</style>

View file

@ -0,0 +1,35 @@
import { registerEntity } from '$lib/entities/registry';
import { habitsStore } from './stores/habits.svelte';
import type { EntityDescriptor } from '$lib/entities/types';
const habitsEntity: EntityDescriptor = {
appId: 'habits',
collection: 'habits',
getDisplayData: (item) => ({
title: `${item.emoji as string} ${item.title as string}`,
subtitle: undefined,
}),
dragType: 'habit',
acceptsDropFrom: ['task'],
transformIncoming: {
task: (source) => ({
title: source.title as string,
emoji: '\ud83d\udcaa',
color: '#6366f1',
}),
},
createItem: async (data) => {
const habit = await habitsStore.createHabit({
title: data.title as string,
emoji: (data.emoji as string) ?? '\u2b50',
color: (data.color as string) ?? '#6366f1',
});
return habit.id;
},
};
registerEntity(habitsEntity);

View file

@ -0,0 +1,30 @@
/**
* Habits module barrel exports.
*/
// ─── Stores ──────────────────────────────────────────────
export { habitsStore } from './stores/habits.svelte';
// ─── Queries ─────────────────────────────────────────────
export {
useAllHabits,
useAllHabitLogs,
useHabitLogsForHabit,
toHabit,
toHabitLog,
todayStr,
getLogsForDate,
getCountForDate,
getActiveHabits,
getTodayCounts,
getStreak,
groupLogsByDate,
formatTime,
} from './queries';
// ─── Collections ─────────────────────────────────────────
export { habitTable, habitLogTable, HABITS_GUEST_SEED } from './collections';
// ─── Types ───────────────────────────────────────────────
export { HABIT_COLORS, HABIT_EMOJIS } from './types';
export type { LocalHabit, LocalHabitLog, Habit, HabitLog } from './types';

View file

@ -0,0 +1,138 @@
/**
* Reactive Queries & Pure Helpers for Habits module.
*
* Uses Dexie liveQuery on the unified database.
*/
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import type { LocalHabit, LocalHabitLog, Habit, HabitLog } from './types';
// ─── Type Converters ───────────────────────────────────────
export function toHabit(local: LocalHabit): Habit {
return {
id: local.id,
title: local.title,
emoji: local.emoji,
color: local.color,
targetPerDay: local.targetPerDay,
order: local.order,
isArchived: local.isArchived,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toHabitLog(local: LocalHabitLog): HabitLog {
return {
id: local.id,
habitId: local.habitId,
timestamp: local.timestamp,
note: local.note,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
export function useAllHabits() {
return liveQuery(async () => {
const locals = await db.table<LocalHabit>('habits').orderBy('order').toArray();
return locals.filter((h) => !h.deletedAt).map(toHabit);
});
}
export function useAllHabitLogs() {
return liveQuery(async () => {
const locals = await db.table<LocalHabitLog>('habitLogs').toArray();
return locals.filter((l) => !l.deletedAt).map(toHabitLog);
});
}
export function useHabitLogsForHabit(habitId: string) {
return liveQuery(async () => {
const locals = await db
.table<LocalHabitLog>('habitLogs')
.where('habitId')
.equals(habitId)
.toArray();
return locals
.filter((l) => !l.deletedAt)
.map(toHabitLog)
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
});
}
// ─── Pure Helpers ──────────────────────────────────────────
/** Get today's date string (YYYY-MM-DD) */
export function todayStr(): string {
return new Date().toISOString().split('T')[0];
}
/** Filter logs for a specific date */
export function getLogsForDate(logs: HabitLog[], date: string): HabitLog[] {
return logs.filter((l) => l.timestamp.startsWith(date));
}
/** Count logs for a specific habit on a given date */
export function getCountForDate(logs: HabitLog[], habitId: string, date: string): number {
return logs.filter((l) => l.habitId === habitId && l.timestamp.startsWith(date)).length;
}
/** Get active (non-archived) habits */
export function getActiveHabits(habits: Habit[]): Habit[] {
return habits.filter((h) => !h.isArchived).sort((a, b) => a.order - b.order);
}
/** Get today's count per habit */
export function getTodayCounts(habits: Habit[], logs: HabitLog[]): Map<string, number> {
const today = todayStr();
const counts = new Map<string, number>();
for (const h of habits) {
counts.set(h.id, getCountForDate(logs, h.id, today));
}
return counts;
}
/** Calculate streak (consecutive days with at least one log) */
export function getStreak(logs: HabitLog[], habitId: string): number {
const habitLogs = logs.filter((l) => l.habitId === habitId);
if (habitLogs.length === 0) return 0;
const dates = new Set(habitLogs.map((l) => l.timestamp.split('T')[0]));
let streak = 0;
const d = new Date();
while (true) {
const dateStr = d.toISOString().split('T')[0];
if (dates.has(dateStr)) {
streak++;
d.setDate(d.getDate() - 1);
} else {
break;
}
}
return streak;
}
/** Group logs by date (most recent first) */
export function groupLogsByDate(logs: HabitLog[]): Map<string, HabitLog[]> {
const groups = new Map<string, HabitLog[]>();
const sorted = [...logs].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
for (const log of sorted) {
const date = log.timestamp.split('T')[0];
const existing = groups.get(date) || [];
existing.push(log);
groups.set(date, existing);
}
return groups;
}
/** Format time from ISO string to HH:MM */
export function formatTime(iso: string): string {
const d = new Date(iso);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}

View file

@ -0,0 +1,100 @@
/**
* Habits Store Mutation-Only Service
*
* All reads are handled by liveQuery hooks in queries.ts.
* This store only provides write operations.
*/
import { habitTable, habitLogTable } from '../collections';
import { toHabit } from '../queries';
import type { LocalHabit, LocalHabitLog } from '../types';
export const habitsStore = {
async createHabit(data: {
title: string;
emoji: string;
color: string;
targetPerDay?: number | null;
}) {
const existing = await habitTable.toArray();
const count = existing.filter((h) => !h.deletedAt).length;
const newLocal: LocalHabit = {
id: crypto.randomUUID(),
title: data.title,
emoji: data.emoji,
color: data.color,
targetPerDay: data.targetPerDay ?? null,
order: count,
isArchived: false,
};
await habitTable.add(newLocal);
return toHabit(newLocal);
},
async updateHabit(
id: string,
data: Partial<
Pick<LocalHabit, 'title' | 'emoji' | 'color' | 'targetPerDay' | 'isArchived' | 'order'>
>
) {
await habitTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async deleteHabit(id: string) {
await habitTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Also soft-delete all logs for this habit
const logs = await habitLogTable.where('habitId').equals(id).toArray();
const now = new Date().toISOString();
for (const log of logs) {
await habitLogTable.update(log.id, { deletedAt: now });
}
},
async logHabit(habitId: string, note?: string) {
const newLog: LocalHabitLog = {
id: crypto.randomUUID(),
habitId,
timestamp: new Date().toISOString(),
note: note ?? null,
};
await habitLogTable.add(newLog);
return newLog;
},
async deleteLog(logId: string) {
await habitLogTable.update(logId, {
deletedAt: new Date().toISOString(),
});
},
async undoLastLog(habitId: string) {
const logs = await habitLogTable.where('habitId').equals(habitId).toArray();
const active = logs
.filter((l) => !l.deletedAt)
.sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''));
if (active.length > 0) {
await habitLogTable.update(active[0].id, {
deletedAt: new Date().toISOString(),
});
}
},
async reorderHabits(habitIds: string[]) {
const now = new Date().toISOString();
for (let i = 0; i < habitIds.length; i++) {
await habitTable.update(habitIds[i], {
order: i,
updatedAt: now,
});
}
},
};

View file

@ -0,0 +1,85 @@
/**
* Habits module types for the unified app.
*
* Habit = a trackable behavior (e.g. Coffee, Cigarette, Workout)
* HabitLog = a single occurrence/tally with timestamp
*/
import type { BaseRecord } from '@manacore/local-store';
// ─── Local Record Types (Dexie) ───────────────────────────
export interface LocalHabit extends BaseRecord {
title: string;
emoji: string;
color: string;
targetPerDay: number | null;
order: number;
isArchived: boolean;
}
export interface LocalHabitLog extends BaseRecord {
habitId: string;
timestamp: string; // ISO string
note: string | null;
}
// ─── Domain Types ─────────────────────────────────────────
export interface Habit {
id: string;
title: string;
emoji: string;
color: string;
targetPerDay: number | null;
order: number;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
export interface HabitLog {
id: string;
habitId: string;
timestamp: string;
note: string | null;
createdAt: string;
}
// ─── Constants ────────────────────────────────────────────
export const HABIT_COLORS: string[] = [
'#ef4444',
'#f97316',
'#f59e0b',
'#84cc16',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#a855f7',
'#d946ef',
'#ec4899',
'#f43f5e',
];
export const HABIT_EMOJIS: string[] = [
'\u2615', // coffee
'\ud83d\udeb6', // cigarette / walking
'\ud83c\udfc3', // running
'\ud83e\uddd8', // meditation
'\ud83d\udca7', // water
'\ud83c\udf4e', // apple / healthy food
'\ud83d\udcda', // reading
'\ud83d\udcaa', // workout
'\ud83d\udecc', // sleep
'\ud83c\udfb5', // music
'\ud83d\udc8a', // pill / medicine
'\ud83c\udf7a', // beer
'\ud83c\udf55', // pizza / junk food
'\ud83d\udeb4', // cycling
'\ud83d\udcdd', // journal
'\ud83e\uddfc', // teeth / hygiene
];

View file

@ -41,6 +41,7 @@
{ appId: 'todo', minimized: false },
{ appId: 'calendar', minimized: false },
{ appId: 'contacts', minimized: false },
{ appId: 'habits', minimized: false },
],
});
@ -56,6 +57,7 @@
{ appId: 'todo', minimized: false },
{ appId: 'calendar', minimized: false },
{ appId: 'contacts', minimized: false },
{ appId: 'habits', minimized: false },
]);
// Load persisted state once on mount (not reactive — avoids loop with persistState)

View file

@ -0,0 +1,15 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllHabits, useAllHabitLogs } from '$lib/modules/habits/queries';
let { children }: { children: Snippet } = $props();
const allHabits = useAllHabits();
const allLogs = useAllHabitLogs();
setContext('habits', allHabits);
setContext('habitLogs', allLogs);
</script>
{@render children()}

View file

@ -0,0 +1,122 @@
<script lang="ts">
import { getContext } from 'svelte';
import { goto } from '$app/navigation';
import type { Observable } from 'dexie';
import type { Habit, HabitLog } from '$lib/modules/habits/types';
import { todayStr, getLogsForDate } from '$lib/modules/habits/queries';
import HabitBoard from '$lib/modules/habits/components/HabitBoard.svelte';
import DayTimeline from '$lib/modules/habits/components/DayTimeline.svelte';
const allHabits$: Observable<Habit[]> = getContext('habits');
const allLogs$: Observable<HabitLog[]> = getContext('habitLogs');
let habits = $state<Habit[]>([]);
let logs = $state<HabitLog[]>([]);
let isLoaded = $state(false);
$effect(() => {
const sub = allHabits$.subscribe((h) => {
habits = h;
isLoaded = true;
});
return () => sub.unsubscribe();
});
$effect(() => {
const sub = allLogs$.subscribe((l) => (logs = l));
return () => sub.unsubscribe();
});
let today = todayStr();
let todayLogs = $derived(getLogsForDate(logs, today));
function handleDetail(habit: Habit) {
goto(`/habits/${habit.id}`);
}
</script>
<svelte:head>
<title>Habits - ManaCore</title>
</svelte:head>
<div class="habits-page">
<header class="habits-header">
<div>
<h1 class="habits-title">Habits</h1>
{#if isLoaded}
<div class="habits-stats">
<span>{habits.filter((h) => !h.isArchived).length} Habits</span>
<span>{todayLogs.length} Einträge heute</span>
</div>
{/if}
</div>
</header>
{#if isLoaded}
<section class="board-section">
<HabitBoard {habits} {logs} onDetail={handleDetail} />
</section>
{#if todayLogs.length > 0}
<section class="timeline-section">
<h2 class="section-title">Heute</h2>
<DayTimeline {logs} {habits} date={today} />
</section>
{/if}
{:else}
<div class="loading">Laden...</div>
{/if}
</div>
<style>
.habits-page {
min-height: calc(100vh - 140px);
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 0 1rem;
max-width: 640px;
}
.habits-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.habits-title {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-foreground);
}
.habits-stats {
display: flex;
gap: 1rem;
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--color-muted-foreground);
}
.board-section {
/* main content */
}
.timeline-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-foreground);
}
.loading {
color: var(--color-muted-foreground);
text-align: center;
padding: 3rem 0;
}
</style>

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import type { Observable } from 'dexie';
import type { Habit, HabitLog } from '$lib/modules/habits/types';
import HabitDetail from '$lib/modules/habits/components/HabitDetail.svelte';
const allHabits$: Observable<Habit[]> = getContext('habits');
const allLogs$: Observable<HabitLog[]> = getContext('habitLogs');
let habits = $state<Habit[]>([]);
let logs = $state<HabitLog[]>([]);
$effect(() => {
const sub = allHabits$.subscribe((h) => (habits = h));
return () => sub.unsubscribe();
});
$effect(() => {
const sub = allLogs$.subscribe((l) => (logs = l));
return () => sub.unsubscribe();
});
let habitId = $derived($page.params.id);
let habit = $derived(habits.find((h) => h.id === habitId));
function handleBack() {
goto('/habits');
}
</script>
<svelte:head>
<title>{habit ? `${habit.emoji} ${habit.title}` : 'Habit'} - ManaCore</title>
</svelte:head>
<div class="detail-page">
{#if habit}
<HabitDetail {habit} {logs} onBack={handleBack} />
{:else if habits.length > 0}
<div class="not-found">
<p>Habit nicht gefunden.</p>
<button onclick={handleBack}>Zurück</button>
</div>
{:else}
<div class="loading">Laden...</div>
{/if}
</div>
<style>
.detail-page {
padding: 0 1rem;
}
.not-found {
text-align: center;
padding: 3rem 0;
color: var(--color-muted-foreground);
}
.not-found button {
margin-top: 1rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
background: var(--color-primary, #6366f1);
color: white;
border: none;
cursor: pointer;
}
.loading {
color: var(--color-muted-foreground);
text-align: center;
padding: 3rem 0;
}
</style>