mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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:
parent
0af8c7cec6
commit
5828f60934
21 changed files with 2455 additions and 1 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
};
|
||||
|
|
|
|||
543
apps/manacore/apps/web/src/lib/modules/habits/ListView.svelte
Normal file
543
apps/manacore/apps/web/src/lib/modules/habits/ListView.svelte
Normal 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>
|
||||
49
apps/manacore/apps/web/src/lib/modules/habits/collections.ts
Normal file
49
apps/manacore/apps/web/src/lib/modules/habits/collections.ts
Normal 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[],
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
35
apps/manacore/apps/web/src/lib/modules/habits/entity.ts
Normal file
35
apps/manacore/apps/web/src/lib/modules/habits/entity.ts
Normal 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);
|
||||
30
apps/manacore/apps/web/src/lib/modules/habits/index.ts
Normal file
30
apps/manacore/apps/web/src/lib/modules/habits/index.ts
Normal 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';
|
||||
138
apps/manacore/apps/web/src/lib/modules/habits/queries.ts
Normal file
138
apps/manacore/apps/web/src/lib/modules/habits/queries.ts
Normal 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')}`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
85
apps/manacore/apps/web/src/lib/modules/habits/types.ts
Normal file
85
apps/manacore/apps/web/src/lib/modules/habits/types.ts
Normal 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
|
||||
];
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
122
apps/manacore/apps/web/src/routes/(app)/habits/+page.svelte
Normal file
122
apps/manacore/apps/web/src/routes/(app)/habits/+page.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -131,6 +131,9 @@ export const APP_ICONS = {
|
|||
skilltree: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="sk" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#d97706"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#sk)"/><circle cx="50" cy="30" r="8" fill="white"/><circle cx="30" cy="55" r="7" fill="white" fill-opacity="0.8"/><circle cx="70" cy="55" r="7" fill="white" fill-opacity="0.8"/><circle cx="20" cy="75" r="6" fill="white" fill-opacity="0.5"/><circle cx="42" cy="75" r="6" fill="white" fill-opacity="0.5"/><circle cx="58" cy="75" r="6" fill="white" fill-opacity="0.5"/><circle cx="80" cy="75" r="6" fill="white" fill-opacity="0.5"/><path d="M50 38v0L30 48M50 38L70 48M30 62L20 69M30 62L42 69M70 62L58 69M70 62L80 69" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
|
||||
),
|
||||
habits: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="hb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#8b5cf6"/><stop offset="100%" style="stop-color:#6d28d9"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#hb)"/><path d="M30 55l8 8 16-16" stroke="white" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="50" cy="50" r="24" stroke="white" stroke-width="4" fill="none"/><path d="M50 26v6M50 68v6M26 50h6M68 50h6" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
|
||||
),
|
||||
arcade: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ar" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ef4444"/><stop offset="100%" style="stop-color:#dc2626"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ar)"/><rect x="25" y="30" width="50" height="35" rx="5" stroke="white" stroke-width="4" fill="none"/><path d="M38 65v10M62 65v10M32 75h36" stroke="white" stroke-width="4" stroke-linecap="round"/><circle cx="60" cy="44" r="4" fill="white"/><circle cx="68" cy="50" r="3" fill="white" fill-opacity="0.7"/><path d="M35 44h10M40 39v10" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
|
||||
),
|
||||
|
|
|
|||
|
|
@ -581,6 +581,23 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'beta',
|
||||
requiredTier: 'alpha',
|
||||
},
|
||||
{
|
||||
id: 'habits',
|
||||
name: 'Habits',
|
||||
description: {
|
||||
de: 'Gewohnheiten tracken',
|
||||
en: 'Habit Tracking',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Schnelles Tally-Tracking für Gewohnheiten wie Kaffee, Zigaretten, Wasser — ein Tap pro Eintrag mit Tagesstatistiken und Streaks.',
|
||||
en: 'Quick tally tracking for habits like coffee, cigarettes, water — one tap per entry with daily stats and streaks.',
|
||||
},
|
||||
icon: APP_ICONS.habits,
|
||||
color: '#8b5cf6',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
requiredTier: 'founder',
|
||||
},
|
||||
{
|
||||
id: 'arcade',
|
||||
name: 'Arcade',
|
||||
|
|
@ -709,6 +726,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
|
|||
moodlit: { dev: 'http://localhost:5173/moodlit', prod: 'https://mana.how/moodlit' },
|
||||
memoro: { dev: 'http://localhost:5173/memoro', prod: 'https://mana.how/memoro' },
|
||||
guides: { dev: 'http://localhost:5173/guides', prod: 'https://mana.how/guides' },
|
||||
habits: { dev: 'http://localhost:5173/habits', prod: 'https://mana.how/habits' },
|
||||
wisekeep: { dev: 'http://localhost:5173/wisekeep', prod: 'https://mana.how/wisekeep' },
|
||||
news: { dev: 'http://localhost:5173/news', prod: 'https://mana.how/news' },
|
||||
mail: { dev: 'http://localhost:5173/mail', prod: 'https://mana.how/mail' },
|
||||
|
|
|
|||
|
|
@ -11,7 +11,16 @@
|
|||
|
||||
// ── Drag types ──────────────────────────────────────────────
|
||||
|
||||
export type DragType = 'tag' | 'task' | 'card' | 'photo' | 'file' | 'event' | 'link' | 'contact';
|
||||
export type DragType =
|
||||
| 'tag'
|
||||
| 'task'
|
||||
| 'card'
|
||||
| 'photo'
|
||||
| 'file'
|
||||
| 'event'
|
||||
| 'link'
|
||||
| 'contact'
|
||||
| 'habit';
|
||||
|
||||
export interface DragPayload<T = Record<string, unknown>> {
|
||||
type: DragType;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue