feat(timeblocks): focus mode, habit scheduling, smart slots, multi-type quick-create

Focus Mode:
- focusStore with Pomodoro timer (25min focus / 5min break / 15min long break)
- FocusCard component with circular progress ring and phase controls
- Creates type:'focus' and type:'break' timeBlocks (appear in calendar)

Habit Scheduling:
- HabitSchedule type: days[] + optional time
- Schedule UI in HabitForm (day picker circles + time input)
- generateScheduledBlocks() creates planned timeBlocks for the week
- logFromScheduled() links scheduled → logged blocks (plan vs reality)

Smart Scheduling:
- findFreeSlots() and findNextFreeSlot() queries in time-blocks/queries.ts
- SlotSuggestions component shows available slots inline
- Integrated in Todo DetailView for one-click task scheduling

Multi-Type Quick-Create:
- QuickEventPopover type selector pills (Termin / Zeiterfassung / Habit)
- Calendar page handles all 3 types: creates appropriate domain record + timeBlock
- Calendar pills hidden when creating non-event types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-05 17:21:37 +02:00
parent 22e06ef803
commit 63d3ba7e5e
12 changed files with 1095 additions and 11 deletions

View file

@ -190,6 +190,89 @@ export function getBlockDuration(block: TimeBlock): number {
);
}
/** Find free time slots on a given day. */
export function findFreeSlots(
blocks: TimeBlock[],
date: Date,
minDurationMinutes: number = 30,
workStart: number = 8,
workEnd: number = 18
): { start: Date; end: Date; durationMinutes: number }[] {
// Get non-allday blocks for the day, sorted by start
const dayBlocks = getBlocksForDay(blocks, date)
.filter((b) => !b.allDay && b.endDate)
.sort((a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime());
const slots: { start: Date; end: Date; durationMinutes: number }[] = [];
const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate(), workStart, 0, 0);
const dayEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate(), workEnd, 0, 0);
let cursor = dayStart;
for (const block of dayBlocks) {
const blockStart = new Date(block.startDate);
const blockEnd = new Date(block.endDate!);
// Skip blocks outside working hours
if (blockEnd <= dayStart || blockStart >= dayEnd) continue;
const effectiveStart = blockStart < dayStart ? dayStart : blockStart;
if (cursor < effectiveStart) {
const gapMinutes = (effectiveStart.getTime() - cursor.getTime()) / 60000;
if (gapMinutes >= minDurationMinutes) {
slots.push({
start: new Date(cursor),
end: effectiveStart,
durationMinutes: Math.round(gapMinutes),
});
}
}
const effectiveEnd = blockEnd > dayEnd ? dayEnd : blockEnd;
if (effectiveEnd > cursor) {
cursor = effectiveEnd;
}
}
// Gap after last block until end of work
if (cursor < dayEnd) {
const gapMinutes = (dayEnd.getTime() - cursor.getTime()) / 60000;
if (gapMinutes >= minDurationMinutes) {
slots.push({ start: new Date(cursor), end: dayEnd, durationMinutes: Math.round(gapMinutes) });
}
}
return slots;
}
/** Find the next free slot across multiple days. */
export function findNextFreeSlot(
blocks: TimeBlock[],
minDurationMinutes: number = 60,
daysToSearch: number = 7,
workStart: number = 8,
workEnd: number = 18
): { start: Date; end: Date; durationMinutes: number } | null {
const today = new Date();
for (let d = 0; d < daysToSearch; d++) {
const date = new Date(today);
date.setDate(date.getDate() + d);
const slots = findFreeSlots(blocks, date, minDurationMinutes, workStart, workEnd);
if (slots.length > 0) {
// For today, skip slots that have already started
if (d === 0) {
const now = new Date();
const validSlot = slots.find((s) => s.start >= now);
if (validSlot) return validSlot;
} else {
return slots[0];
}
}
}
return null;
}
/** Group timeBlocks by date string (YYYY-MM-DD). */
export function groupBlocksByDate(blocks: TimeBlock[]): Map<string, TimeBlock[]> {
const map = new Map<string, TimeBlock[]>();

View file

@ -15,6 +15,11 @@
} from '@manacore/shared-icons';
import ConflictWarning from './ConflictWarning.svelte';
import type { TimeBlockType } from '$lib/data/time-blocks/types';
import { CheckSquare, Timer, Heart } from '@manacore/shared-icons';
type QuickCreateType = 'event' | 'timeEntry' | 'habit';
interface Props {
startTime: Date;
endTime: Date;
@ -28,6 +33,7 @@
location: string | null;
description: string | null;
recurrenceRule: string | null;
blockType: QuickCreateType;
}) => void;
onClose: () => void;
}
@ -39,6 +45,7 @@
let title = $state('');
let location = $state('');
let description = $state('');
let blockType = $state<QuickCreateType>('event');
let isAllDay = $state(false);
let recurrenceRule = $state<string | null>(null);
let startDateStr = $state(format(startTime, 'yyyy-MM-dd'));
@ -90,6 +97,7 @@
location: location.trim() || null,
description: description.trim() || null,
recurrenceRule: recurrenceRule || null,
blockType,
});
}
@ -158,8 +166,36 @@
required
/>
<!-- Type selector -->
<div class="type-pills">
<button
type="button"
class="type-pill"
class:active={blockType === 'event'}
onclick={() => (blockType = 'event')}
>
<CalendarBlank size={12} /> Termin
</button>
<button
type="button"
class="type-pill"
class:active={blockType === 'timeEntry'}
onclick={() => (blockType = 'timeEntry')}
>
<Timer size={12} /> Zeiterfassung
</button>
<button
type="button"
class="type-pill"
class:active={blockType === 'habit'}
onclick={() => (blockType = 'habit')}
>
<Heart size={12} /> Habit
</button>
</div>
<!-- Calendar pills -->
{#if calendarsCtx.value.length > 1}
{#if calendarsCtx.value.length > 1 && blockType === 'event'}
<div class="calendar-pills">
{#each calendarsCtx.value as cal (cal.id)}
<button
@ -329,6 +365,33 @@
background: hsl(var(--color-muted));
}
.type-pills {
display: flex;
gap: 0.25rem;
}
.type-pill {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 9999px;
background: transparent;
font-size: 0.6875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.type-pill:hover {
background: hsl(var(--color-muted));
}
.type-pill.active {
background: hsl(var(--color-primary) / 0.1);
border-color: hsl(var(--color-primary) / 0.3);
color: hsl(var(--color-primary));
}
.popover-content {
flex: 1;
overflow-y: auto;

View file

@ -0,0 +1,136 @@
<!--
SlotSuggestions — Shows available free time slots.
Used in task scheduling and event creation to suggest optimal times.
-->
<script lang="ts">
import { db } from '$lib/data/database';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { toTimeBlock, findFreeSlots } from '$lib/data/time-blocks/queries';
import type { TimeBlock } from '$lib/data/time-blocks/types';
import { CalendarBlank } from '@manacore/shared-icons';
import { format, addDays, isToday, isTomorrow } from 'date-fns';
import { de } from 'date-fns/locale';
let {
minDurationMinutes = 30,
onSelect,
}: {
minDurationMinutes?: number;
onSelect: (start: Date, end: Date) => void;
} = $props();
let slots = $state<{ date: Date; start: Date; end: Date; durationMinutes: number }[]>([]);
let loading = $state(true);
$effect(() => {
loadSlots();
});
async function loadSlots() {
loading = true;
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const blocks = locals.filter((b) => !b.deletedAt).map(toTimeBlock);
const results: typeof slots = [];
const today = new Date();
for (let d = 0; d < 5 && results.length < 6; d++) {
const date = addDays(today, d);
const daySlots = findFreeSlots(blocks, date, minDurationMinutes);
for (const slot of daySlots) {
if (results.length >= 6) break;
// For today, only future slots
if (d === 0 && slot.start < today) continue;
results.push({ date, ...slot });
}
}
slots = results;
loading = false;
}
function formatDayLabel(date: Date): string {
if (isToday(date)) return 'Heute';
if (isTomorrow(date)) return 'Morgen';
return format(date, 'EEE, d. MMM', { locale: de });
}
</script>
{#if loading}
<div class="slot-loading">Suche freie Zeiten...</div>
{:else if slots.length === 0}
<div class="slot-empty">Keine freien Slots gefunden</div>
{:else}
<div class="slot-list">
<span class="slot-label">Freie Zeiten</span>
{#each slots as slot}
<button class="slot-btn" onclick={() => onSelect(slot.start, slot.end)}>
<CalendarBlank size={12} />
<span class="slot-day">{formatDayLabel(slot.date)}</span>
<span class="slot-time">
{format(slot.start, 'HH:mm')}{format(slot.end, 'HH:mm')}
</span>
<span class="slot-duration">{slot.durationMinutes}min</span>
</button>
{/each}
</div>
{/if}
<style>
.slot-loading,
.slot-empty {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
padding: 0.25rem 0;
}
.slot-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.slot-label {
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--color-muted-foreground));
padding-bottom: 0.125rem;
}
.slot-btn {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.375rem;
background: transparent;
font-size: 0.75rem;
color: hsl(var(--color-foreground));
cursor: pointer;
transition: all 0.15s;
text-align: left;
}
.slot-btn:hover {
background: hsl(var(--color-primary) / 0.05);
border-color: hsl(var(--color-primary) / 0.3);
}
.slot-day {
font-weight: 500;
min-width: 4rem;
}
.slot-time {
font-variant-numeric: tabular-nums;
}
.slot-duration {
margin-left: auto;
color: hsl(var(--color-muted-foreground));
font-size: 0.6875rem;
}
</style>

View file

@ -4,7 +4,7 @@
-->
<script lang="ts">
import { habitsStore } from '../stores/habits.svelte';
import { HABIT_COLORS, type Habit } from '../types';
import { HABIT_COLORS, type Habit, type HabitSchedule } from '../types';
import { DynamicIcon } from '@manacore/shared-ui/atoms';
import { IconPicker } from '@manacore/shared-ui/molecules';
@ -27,12 +27,23 @@
);
let showIconPicker = $state(false);
// Schedule state
let hasSchedule = $state(!!habit?.schedule);
let scheduleDays = $state<number[]>(habit?.schedule?.days ?? [1, 2, 3, 4, 5]); // Mon-Fri default
let scheduleTime = $state(habit?.schedule?.time ?? '');
const dayLabels = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
async function handleSubmit(e: Event) {
e.preventDefault();
if (!title.trim()) return;
const target = targetPerDay.trim() ? parseInt(targetPerDay) : null;
const durationSec = defaultDurationMin.trim() ? parseInt(defaultDurationMin) * 60 : null;
const schedule: HabitSchedule | null =
hasSchedule && scheduleDays.length > 0
? { days: scheduleDays, time: scheduleTime || undefined }
: null;
if (habit) {
await habitsStore.updateHabit(habit.id, {
@ -42,14 +53,18 @@
targetPerDay: target,
defaultDuration: durationSec,
});
await habitsStore.setSchedule(habit.id, schedule);
} else {
await habitsStore.createHabit({
const created = await habitsStore.createHabit({
title: title.trim(),
icon,
color,
targetPerDay: target,
defaultDuration: durationSec,
});
if (schedule && created) {
await habitsStore.setSchedule(created.id, schedule);
}
}
onDone();
@ -135,6 +150,43 @@
</label>
</div>
<!-- Schedule -->
<div class="schedule-section">
<label class="schedule-toggle">
<input type="checkbox" bind:checked={hasSchedule} />
<span>Im Kalender planen</span>
</label>
{#if hasSchedule}
<div class="schedule-days">
{#each dayLabels as label, i}
{@const active = scheduleDays.includes(i)}
<button
type="button"
class="day-btn"
class:active
onclick={() => {
if (active) {
scheduleDays = scheduleDays.filter((d) => d !== i);
} else {
scheduleDays = [...scheduleDays, i].sort();
}
}}
>
{label}
</button>
{/each}
</div>
<input
class="target-input"
type="time"
bind:value={scheduleTime}
placeholder="Uhrzeit (optional)"
style="width: auto;"
/>
{/if}
</div>
<div class="form-actions">
<button type="button" class="btn-cancel" onclick={onCancel}>Abbrechen</button>
<button type="submit" class="btn-save" disabled={!title.trim()}>
@ -247,6 +299,44 @@
border-color: var(--color-primary, #6366f1);
}
.schedule-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.schedule-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--color-muted-foreground);
cursor: pointer;
}
.schedule-days {
display: flex;
gap: 0.25rem;
}
.day-btn {
width: 2rem;
height: 2rem;
border-radius: 50%;
border: 1px solid var(--color-border, rgba(255, 255, 255, 0.15));
background: transparent;
color: var(--color-muted-foreground);
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.day-btn:hover {
border-color: var(--color-primary, #6366f1);
}
.day-btn.active {
background: var(--color-primary, #6366f1);
border-color: var(--color-primary, #6366f1);
color: white;
}
.form-actions {
display: flex;
justify-content: flex-end;

View file

@ -20,6 +20,7 @@ export function toHabit(local: LocalHabit): Habit {
color: local.color,
targetPerDay: local.targetPerDay,
defaultDuration: local.defaultDuration ?? null,
schedule: local.schedule ?? null,
order: local.order,
isArchived: local.isArchived,
createdAt: local.createdAt ?? new Date().toISOString(),

View file

@ -7,8 +7,10 @@
import { habitTable, habitLogTable } from '../collections';
import { toHabit } from '../queries';
import { createBlock, deleteBlock } from '$lib/data/time-blocks/service';
import type { LocalHabit, LocalHabitLog } from '../types';
import { createBlock, deleteBlock, startFromScheduled } from '$lib/data/time-blocks/service';
import { db } from '$lib/data/database';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type { LocalHabit, LocalHabitLog, HabitSchedule } from '../types';
export const habitsStore = {
async createHabit(data: {
@ -137,4 +139,92 @@ export const habitsStore = {
});
}
},
/** Set or clear a recurring schedule for a habit. */
async setSchedule(habitId: string, schedule: HabitSchedule | null) {
await habitTable.update(habitId, {
schedule,
updatedAt: new Date().toISOString(),
});
},
/**
* Generate scheduled TimeBlocks for habits with schedules for the next N days.
* Skips days that already have a scheduled block for that habit.
*/
async generateScheduledBlocks(daysAhead = 7) {
const habits = await habitTable.toArray();
const scheduledHabits = habits.filter((h) => !h.deletedAt && !h.isArchived && h.schedule);
if (scheduledHabits.length === 0) return;
const today = new Date();
today.setHours(0, 0, 0, 0);
// Get existing scheduled habit blocks to avoid duplicates
const weekEnd = new Date(today);
weekEnd.setDate(weekEnd.getDate() + daysAhead);
const existingBlocks = await db
.table<LocalTimeBlock>('timeBlocks')
.where('[type+startDate]')
.between(['habit', today.toISOString()], ['habit', weekEnd.toISOString()], true, true)
.toArray();
const existingKeys = new Set(
existingBlocks
.filter((b) => !b.deletedAt && b.kind === 'scheduled')
.map((b) => `${b.sourceId}-${b.startDate.split('T')[0]}`)
);
for (const habit of scheduledHabits) {
const schedule = habit.schedule!;
for (let d = 0; d < daysAhead; d++) {
const date = new Date(today);
date.setDate(date.getDate() + d);
const dayOfWeek = date.getDay(); // 0=Sun
if (!schedule.days.includes(dayOfWeek)) continue;
const dateStr = date.toISOString().split('T')[0];
const key = `${habit.id}-${dateStr}`;
if (existingKeys.has(key)) continue;
const startTime = schedule.time ?? '09:00';
const startISO = `${dateStr}T${startTime}:00`;
const durationMs = habit.defaultDuration ? habit.defaultDuration * 1000 : 3600000;
const endISO = new Date(new Date(startISO).getTime() + durationMs).toISOString();
await createBlock({
startDate: startISO,
endDate: endISO,
allDay: !schedule.time,
kind: 'scheduled',
type: 'habit',
sourceModule: 'habits',
sourceId: habit.id,
title: habit.title,
color: habit.color,
icon: habit.icon,
});
}
}
},
/**
* Log a habit from a scheduled block (plan reality).
* Creates a logged TimeBlock linked to the scheduled one.
*/
async logFromScheduled(scheduledBlockId: string, habitId: string, note?: string) {
const loggedBlockId = await startFromScheduled(scheduledBlockId);
const newLog: LocalHabitLog = {
id: crypto.randomUUID(),
habitId,
timeBlockId: loggedBlockId,
note: note ?? null,
};
await habitLogTable.add(newLog);
return newLog;
},
};

View file

@ -9,12 +9,18 @@ import type { BaseRecord } from '@manacore/local-store';
// ─── Local Record Types (Dexie) ───────────────────────────
export interface HabitSchedule {
days: number[]; // 0=Sun, 1=Mon, ... 6=Sat
time?: string; // HH:mm (optional, defaults to all-day)
}
export interface LocalHabit extends BaseRecord {
title: string;
icon: string;
color: string;
targetPerDay: number | null;
defaultDuration?: number | null; // seconds (e.g., 300 for a 5min cigarette)
schedule?: HabitSchedule | null; // optional recurring schedule
order: number;
isArchived: boolean;
}
@ -33,7 +39,8 @@ export interface Habit {
icon: string;
color: string;
targetPerDay: number | null;
defaultDuration: number | null; // seconds
defaultDuration: number | null;
schedule: HabitSchedule | null;
order: number;
isArchived: boolean;
createdAt: string;

View file

@ -0,0 +1,340 @@
<!--
FocusCard — Pomodoro focus timer UI.
Shows a circular progress ring, phase indicator, and controls.
-->
<script lang="ts">
import { focusStore } from '../stores/focus.svelte';
import { Lightning, Coffee, Play, Stop, SkipForward } from '@manacore/shared-icons';
let { compact = false }: { compact?: boolean } = $props();
let title = $state('');
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
async function handleStart() {
await focusStore.startFocus({ title: title.trim() || undefined });
title = '';
}
// Auto-transition: focus done → suggest break, break done → suggest focus
$effect(() => {
if (focusStore.isTimerDone && focusStore.phase === 'focus') {
// Play notification sound or vibrate could go here
}
});
const phaseLabel = $derived(
focusStore.phase === 'focus' ? 'Fokus' : focusStore.phase === 'break' ? 'Pause' : 'Bereit'
);
const phaseColor = $derived(
focusStore.phase === 'focus' ? '#ef4444' : focusStore.phase === 'break' ? '#22c55e' : '#6b7280'
);
// SVG ring
const RADIUS = 45;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
let strokeOffset = $derived(CIRCUMFERENCE * (1 - focusStore.progress));
</script>
<div class="focus-card" class:compact>
{#if focusStore.phase === 'idle'}
<!-- Idle state -->
<div class="idle-content">
<div class="idle-header">
<Lightning size={20} weight="bold" />
<span class="focus-label">Fokus-Modus</span>
</div>
<input
class="focus-input"
type="text"
bind:value={title}
placeholder="Woran arbeitest du?"
onkeydown={(e) => {
if (e.key === 'Enter') handleStart();
}}
/>
<div class="idle-info">
<span>{focusStore.focusMinutes}min Fokus</span>
<span>{focusStore.breakMinutes}min Pause</span>
{#if focusStore.completedSessions > 0}
<span>{focusStore.completedSessions} Sessions</span>
{/if}
</div>
<button class="start-btn" onclick={handleStart}>
<Play size={16} weight="fill" />
Fokus starten
</button>
</div>
{:else}
<!-- Active state (focus or break) -->
<div class="active-content">
<div class="phase-header">
{#if focusStore.phase === 'focus'}
<Lightning size={16} weight="bold" style="color: {phaseColor}" />
{:else}
<Coffee size={16} weight="bold" style="color: {phaseColor}" />
{/if}
<span class="phase-label" style="color: {phaseColor}">{phaseLabel}</span>
<span class="session-count">{focusStore.completedSessions} Sessions</span>
</div>
<!-- Timer ring -->
<div class="timer-ring">
<svg viewBox="0 0 100 100" class="ring-svg">
<circle
cx="50"
cy="50"
r={RADIUS}
fill="none"
stroke="hsl(var(--color-border))"
stroke-width="4"
/>
<circle
cx="50"
cy="50"
r={RADIUS}
fill="none"
stroke={phaseColor}
stroke-width="4"
stroke-linecap="round"
stroke-dasharray={CIRCUMFERENCE}
stroke-dashoffset={strokeOffset}
transform="rotate(-90 50 50)"
class="progress-ring"
/>
</svg>
<div class="timer-display">
<span class="timer-time" class:done={focusStore.isTimerDone}>
{formatTime(focusStore.remainingSeconds)}
</span>
</div>
</div>
<!-- Controls -->
<div class="controls">
{#if focusStore.isTimerDone}
{#if focusStore.phase === 'focus'}
<button class="control-btn primary" onclick={() => focusStore.startBreak()}>
<Coffee size={16} />
Pause
</button>
{:else}
<button class="control-btn primary" onclick={handleStart}>
<Lightning size={16} />
Weiter
</button>
{/if}
{:else if focusStore.phase === 'focus'}
<button class="control-btn" onclick={() => focusStore.startBreak()}>
<SkipForward size={16} />
</button>
{/if}
<button class="control-btn danger" onclick={() => focusStore.stop()}>
<Stop size={16} />
</button>
</div>
</div>
{/if}
</div>
<style>
.focus-card {
border-radius: 1rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
padding: 1.5rem;
}
/* Idle */
.idle-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.idle-header {
display: flex;
align-items: center;
gap: 0.5rem;
color: hsl(var(--color-foreground));
}
.focus-label {
font-size: 1rem;
font-weight: 600;
}
.focus-input {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: hsl(var(--color-input, var(--color-background)));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
outline: none;
}
.focus-input:focus {
border-color: hsl(var(--color-primary));
}
.focus-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.idle-info {
display: flex;
gap: 0.75rem;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.start-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem;
border: none;
border-radius: 0.75rem;
background: #ef4444;
color: white;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.start-btn:hover {
opacity: 0.9;
}
/* Active */
.active-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.phase-header {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.phase-label {
font-size: 0.875rem;
font-weight: 600;
}
.session-count {
margin-left: auto;
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
/* Timer ring */
.timer-ring {
position: relative;
width: 140px;
height: 140px;
}
.ring-svg {
width: 100%;
height: 100%;
}
.progress-ring {
transition: stroke-dashoffset 1s linear;
}
.timer-display {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.timer-time {
font-size: 1.75rem;
font-weight: 700;
font-variant-numeric: tabular-nums;
color: hsl(var(--color-foreground));
}
.timer-time.done {
animation: pulse-text 1s ease-in-out infinite;
}
@keyframes pulse-text {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* Controls */
.controls {
display: flex;
gap: 0.5rem;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.375rem;
padding: 0.5rem 1rem;
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
background: transparent;
color: hsl(var(--color-muted-foreground));
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.control-btn:hover {
background: hsl(var(--color-muted));
color: hsl(var(--color-foreground));
}
.control-btn.primary {
background: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.control-btn.primary:hover {
opacity: 0.9;
}
.control-btn.danger:hover {
color: #ef4444;
border-color: #ef4444;
}
/* Compact mode */
.compact {
padding: 1rem;
}
.compact .timer-ring {
width: 100px;
height: 100px;
}
.compact .timer-time {
font-size: 1.25rem;
}
</style>

View file

@ -4,6 +4,7 @@
// ─── Times Stores ─────────────────────────────────────────
export { timerStore } from './stores/timer.svelte';
export { focusStore } from './stores/focus.svelte';
export { viewStore } from './stores/view.svelte';
// ─── Clock Stores (merged from clock module) ──────────────

View file

@ -0,0 +1,198 @@
/**
* Focus Mode Store Pomodoro-style focus sessions using timeBlocks.
*
* Creates type:'focus' and type:'break' timeBlocks.
* Sessions can optionally link to a task via projectId/sourceId.
*/
import { browser } from '$app/environment';
import { db } from '$lib/data/database';
import { createBlock, updateBlock } from '$lib/data/time-blocks/service';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
export type FocusPhase = 'idle' | 'focus' | 'break';
const DEFAULT_FOCUS_MINUTES = 25;
const DEFAULT_BREAK_MINUTES = 5;
const DEFAULT_LONG_BREAK_MINUTES = 15;
const SESSIONS_BEFORE_LONG_BREAK = 4;
let phase = $state<FocusPhase>('idle');
let activeBlockId = $state<string | null>(null);
let startedAt = $state<Date | null>(null);
let elapsedSeconds = $state(0);
let completedSessions = $state(0);
let focusMinutes = $state(DEFAULT_FOCUS_MINUTES);
let breakMinutes = $state(DEFAULT_BREAK_MINUTES);
let longBreakMinutes = $state(DEFAULT_LONG_BREAK_MINUTES);
let tickInterval: ReturnType<typeof setInterval> | null = null;
function startTicking() {
stopTicking();
tickInterval = setInterval(() => {
if (startedAt) {
elapsedSeconds = Math.floor((Date.now() - startedAt.getTime()) / 1000);
}
}, 1000);
}
function stopTicking() {
if (tickInterval) {
clearInterval(tickInterval);
tickInterval = null;
}
}
/** Seconds remaining in current phase. */
function targetSeconds(): number {
if (phase === 'focus') return focusMinutes * 60;
if (phase === 'break') {
const isLongBreak =
completedSessions > 0 && completedSessions % SESSIONS_BEFORE_LONG_BREAK === 0;
return (isLongBreak ? longBreakMinutes : breakMinutes) * 60;
}
return 0;
}
export const focusStore = {
get phase() {
return phase;
},
get activeBlockId() {
return activeBlockId;
},
get elapsedSeconds() {
return elapsedSeconds;
},
get completedSessions() {
return completedSessions;
},
get focusMinutes() {
return focusMinutes;
},
get breakMinutes() {
return breakMinutes;
},
get remainingSeconds() {
return Math.max(0, targetSeconds() - elapsedSeconds);
},
get progress() {
const target = targetSeconds();
if (target === 0) return 0;
return Math.min(1, elapsedSeconds / target);
},
get isTimerDone() {
return phase !== 'idle' && elapsedSeconds >= targetSeconds();
},
/** Initialize: check for any live focus/break block. */
async initialize() {
if (!browser) return;
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const live = blocks.find(
(b) =>
b.isLive &&
!b.deletedAt &&
(b.type === 'focus' || b.type === 'break') &&
b.sourceModule === 'times'
);
if (live) {
activeBlockId = live.id;
phase = live.type as FocusPhase;
startedAt = new Date(live.startDate);
elapsedSeconds = Math.floor((Date.now() - startedAt.getTime()) / 1000);
startTicking();
}
},
/** Start a focus session. */
async startFocus(options?: { title?: string; projectId?: string }) {
if (phase !== 'idle') await focusStore.stop();
const now = new Date();
const blockId = await createBlock({
startDate: now.toISOString(),
endDate: null,
isLive: true,
kind: 'logged',
type: 'focus',
sourceModule: 'times',
sourceId: `focus-${crypto.randomUUID()}`,
title: options?.title || 'Fokus-Session',
projectId: options?.projectId ?? null,
color: '#ef4444',
});
activeBlockId = blockId;
phase = 'focus';
startedAt = now;
elapsedSeconds = 0;
startTicking();
},
/** Start a break. */
async startBreak() {
if (activeBlockId) {
await updateBlock(activeBlockId, {
endDate: new Date().toISOString(),
isLive: false,
});
}
const now = new Date();
const isLongBreak = (completedSessions + 1) % SESSIONS_BEFORE_LONG_BREAK === 0;
const blockId = await createBlock({
startDate: now.toISOString(),
endDate: null,
isLive: true,
kind: 'logged',
type: 'break',
sourceModule: 'times',
sourceId: `break-${crypto.randomUUID()}`,
title: isLongBreak ? 'Lange Pause' : 'Kurze Pause',
color: '#22c55e',
});
completedSessions++;
activeBlockId = blockId;
phase = 'break';
startedAt = now;
elapsedSeconds = 0;
startTicking();
},
/** Stop current phase and return to idle. */
async stop() {
if (activeBlockId) {
await updateBlock(activeBlockId, {
endDate: new Date().toISOString(),
isLive: false,
});
}
stopTicking();
phase = 'idle';
activeBlockId = null;
startedAt = null;
elapsedSeconds = 0;
},
/** Reset session counter. */
resetSessions() {
completedSessions = 0;
},
/** Configure durations. */
setDurations(focus: number, brk: number, longBrk: number) {
focusMinutes = focus;
breakMinutes = brk;
longBreakMinutes = longBrk;
},
destroy() {
stopTicking();
},
};

View file

@ -9,6 +9,7 @@
import { getBlock } from '$lib/data/time-blocks/service';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { Check, Trash, X, CalendarBlank } from '@manacore/shared-icons';
import SlotSuggestions from '$lib/modules/calendar/components/SlotSuggestions.svelte';
import type { ViewProps } from '$lib/app-registry';
import type { LocalTask, TaskPriority } from '../types';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
@ -247,10 +248,23 @@
</button>
</div>
{:else}
<button class="schedule-btn" onclick={toggleSchedule}>
<CalendarBlank size={14} />
Planen
</button>
<div class="schedule-options">
<button class="schedule-btn" onclick={toggleSchedule}>
<CalendarBlank size={14} />
Planen
</button>
<SlotSuggestions
minDurationMinutes={task.estimatedDuration
? Math.round(task.estimatedDuration / 60)
: 60}
onSelect={(start, end) => {
isScheduled = true;
scheduleDate = start.toISOString().split('T')[0];
scheduleTime = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`;
saveField();
}}
/>
</div>
{/if}
</div>
</div>
@ -446,6 +460,11 @@
align-items: center;
gap: 0.375rem;
}
.schedule-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.schedule-btn {
display: flex;
align-items: center;

View file

@ -110,7 +110,7 @@
showQuickCreate = true;
}
function handleQuickSave(data: {
async function handleQuickSave(data: {
title: string;
calendarId: string;
startTime: string;
@ -119,7 +119,63 @@
location: string | null;
description: string | null;
recurrenceRule: string | null;
blockType?: string;
}) {
if (data.blockType === 'timeEntry') {
// Create a time entry via TimeBlock + LocalTimeEntry
const { createBlock } = await import('$lib/data/time-blocks/service');
const { timeEntryTable } = await import('$lib/modules/times/collections');
const entryId = crypto.randomUUID();
const timeBlockId = await createBlock({
startDate: data.startTime,
endDate: data.endTime,
kind: 'logged',
type: 'timeEntry',
sourceModule: 'times',
sourceId: entryId,
title: data.title,
});
await timeEntryTable.add({
id: entryId,
timeBlockId,
description: data.title,
duration: Math.round(
(new Date(data.endTime).getTime() - new Date(data.startTime).getTime()) / 1000
),
isBillable: false,
tags: [],
visibility: 'private',
source: { app: 'manual' },
});
showQuickCreate = false;
return;
}
if (data.blockType === 'habit') {
// Create a habit log via TimeBlock + LocalHabitLog
const { createBlock } = await import('$lib/data/time-blocks/service');
const { habitLogTable } = await import('$lib/modules/habits/collections');
const logId = crypto.randomUUID();
const timeBlockId = await createBlock({
startDate: data.startTime,
endDate: data.endTime,
kind: 'logged',
type: 'habit',
sourceModule: 'habits',
sourceId: logId,
title: data.title,
});
await habitLogTable.add({
id: logId,
habitId: '', // No specific habit linked
timeBlockId,
note: null,
});
showQuickCreate = false;
return;
}
// Default: create calendar event
eventsStore.createEvent({
calendarId: data.calendarId,
title: data.title,