diff --git a/apps/manacore/apps/web/src/lib/data/time-blocks/queries.ts b/apps/manacore/apps/web/src/lib/data/time-blocks/queries.ts index 07bcb63bf..25d5bd5c9 100644 --- a/apps/manacore/apps/web/src/lib/data/time-blocks/queries.ts +++ b/apps/manacore/apps/web/src/lib/data/time-blocks/queries.ts @@ -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 { const map = new Map(); diff --git a/apps/manacore/apps/web/src/lib/modules/calendar/components/QuickEventPopover.svelte b/apps/manacore/apps/web/src/lib/modules/calendar/components/QuickEventPopover.svelte index 77d5c2327..464773d67 100644 --- a/apps/manacore/apps/web/src/lib/modules/calendar/components/QuickEventPopover.svelte +++ b/apps/manacore/apps/web/src/lib/modules/calendar/components/QuickEventPopover.svelte @@ -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('event'); let isAllDay = $state(false); let recurrenceRule = $state(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 /> + +
+ + + +
+ - {#if calendarsCtx.value.length > 1} + {#if calendarsCtx.value.length > 1 && blockType === 'event'}
{#each calendarsCtx.value as cal (cal.id)} + {/each} +
+{/if} + + diff --git a/apps/manacore/apps/web/src/lib/modules/habits/components/HabitForm.svelte b/apps/manacore/apps/web/src/lib/modules/habits/components/HabitForm.svelte index 1bf07428e..e03cf03f5 100644 --- a/apps/manacore/apps/web/src/lib/modules/habits/components/HabitForm.svelte +++ b/apps/manacore/apps/web/src/lib/modules/habits/components/HabitForm.svelte @@ -4,7 +4,7 @@ --> + +
+ {#if focusStore.phase === 'idle'} + +
+
+ + Fokus-Modus +
+ + { + if (e.key === 'Enter') handleStart(); + }} + /> + +
+ {focusStore.focusMinutes}min Fokus + {focusStore.breakMinutes}min Pause + {#if focusStore.completedSessions > 0} + {focusStore.completedSessions} Sessions + {/if} +
+ + +
+ {:else} + +
+
+ {#if focusStore.phase === 'focus'} + + {:else} + + {/if} + {phaseLabel} + {focusStore.completedSessions} Sessions +
+ + +
+ + + + +
+ + {formatTime(focusStore.remainingSeconds)} + +
+
+ + +
+ {#if focusStore.isTimerDone} + {#if focusStore.phase === 'focus'} + + {:else} + + {/if} + {:else if focusStore.phase === 'focus'} + + {/if} + + +
+
+ {/if} +
+ + diff --git a/apps/manacore/apps/web/src/lib/modules/times/index.ts b/apps/manacore/apps/web/src/lib/modules/times/index.ts index d4621db45..a9e8819e7 100644 --- a/apps/manacore/apps/web/src/lib/modules/times/index.ts +++ b/apps/manacore/apps/web/src/lib/modules/times/index.ts @@ -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) ────────────── diff --git a/apps/manacore/apps/web/src/lib/modules/times/stores/focus.svelte.ts b/apps/manacore/apps/web/src/lib/modules/times/stores/focus.svelte.ts new file mode 100644 index 000000000..b12b02659 --- /dev/null +++ b/apps/manacore/apps/web/src/lib/modules/times/stores/focus.svelte.ts @@ -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('idle'); +let activeBlockId = $state(null); +let startedAt = $state(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 | 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('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(); + }, +}; diff --git a/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte b/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte index 1cc6ab094..e2052282c 100644 --- a/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte +++ b/apps/manacore/apps/web/src/lib/modules/todo/views/DetailView.svelte @@ -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 @@ {:else} - +
+ + { + isScheduled = true; + scheduleDate = start.toISOString().split('T')[0]; + scheduleTime = `${String(start.getHours()).padStart(2, '0')}:${String(start.getMinutes()).padStart(2, '0')}`; + saveField(); + }} + /> +
{/if} @@ -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; diff --git a/apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte b/apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte index 10a3bee00..6e8af37b5 100644 --- a/apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte +++ b/apps/manacore/apps/web/src/routes/(app)/calendar/+page.svelte @@ -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,