mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
22e06ef803
commit
63d3ba7e5e
12 changed files with 1095 additions and 11 deletions
|
|
@ -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[]>();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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) ──────────────
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue