feat(manacore/web): unified time model — timeBlocks for all time data

Introduces a central `timeBlocks` table that owns the time dimension
(start, end, recurrence, live status) for all modules. Calendar, times,
habits, and todo modules keep only domain-specific data with a
timeBlockId reference. The calendar becomes a universal time view
showing events, tasks, habits, and time entries from all modules.

Key changes:
- New `$lib/data/time-blocks/` module (types, service, queries, collections)
- Dexie schema v3 with timeBlocks table + migration from existing data
- Calendar events store creates TimeBlock + LocalEvent pairs
- Times timer uses TimeBlock.isLive instead of LocalTimeEntry.isRunning
- Habits logHabit creates point-event TimeBlocks (with optional duration)
- Todo scheduled tasks create TimeBlock via scheduledBlockId
- Calendar views filter by blockType, show items from all modules
- All calendar views use getItemColor() for cross-module color support

Also includes mukke → music module rename.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-05 14:39:00 +02:00
parent 5fd9c1d11e
commit 0aa0d7b135
55 changed files with 1334 additions and 331 deletions

View file

@ -9,14 +9,14 @@ import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from './database';
import type { LocalTask } from '$lib/modules/todo/types';
import type { LocalEvent } from '$lib/modules/calendar/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import type { LocalContact } from '$lib/modules/contacts/types';
import type { LocalConversation } from '$lib/modules/chat/types';
import type { LocalFavorite } from '$lib/modules/zitare/types';
import type { LocalImage } from '$lib/modules/picture/types';
import type { LocalAlarm, LocalCountdownTimer } from '$lib/modules/times/types';
import type { LocalFile } from '$lib/modules/storage/types';
import type { LocalSong, LocalPlaylist } from '$lib/modules/mukke/types';
import type { LocalSong, LocalPlaylist } from '$lib/modules/music/types';
import type { LocalDeck as LocalPresiDeck } from '$lib/modules/presi/types';
import type { LocalDocument, LocalContextSpace } from '$lib/modules/context/types';
import type { LocalDeck as LocalCardDeck, LocalCard } from '$lib/modules/cards/types';
@ -70,7 +70,7 @@ export function useUpcomingTasks(days = 7) {
// ─── Calendar Queries ───────────────────────────────────────
/** Events in the next N days. */
/** TimeBlocks (all types) in the next N days. */
export function useUpcomingEvents(days = 7) {
return useLiveQueryWithDefault(async () => {
const now = new Date();
@ -80,12 +80,12 @@ export function useUpcomingEvents(days = 7) {
const nowStr = now.toISOString();
const futureStr = future.toISOString();
const all = await db.table<LocalEvent>('events').orderBy('startDate').toArray();
return all.filter((e) => {
if (e.deletedAt) return false;
return e.startDate >= nowStr && e.startDate <= futureStr;
const all = await db.table<LocalTimeBlock>('timeBlocks').orderBy('startDate').toArray();
return all.filter((b) => {
if (b.deletedAt) return false;
return b.startDate >= nowStr && b.startDate <= futureStr;
});
}, [] as LocalEvent[]);
}, [] as LocalTimeBlock[]);
}
// ─── Contacts Queries ───────────────────────────────────────
@ -181,19 +181,19 @@ export function useStorageStats() {
);
}
// ─── Mukke Queries ──────────────────────────────────────────
// ─── Music Queries ──────────────────────────────────────────
interface MukkeStats {
interface MusicStats {
totalSongs: number;
totalPlaylists: number;
favoriteCount: number;
recentSongs: LocalSong[];
}
/** Mukke library stats + recent songs. */
export function useMukkeStats() {
/** Music library stats + recent songs. */
export function useMusicStats() {
return useLiveQueryWithDefault(
async (): Promise<MukkeStats> => {
async (): Promise<MusicStats> => {
const songs = await db.table<LocalSong>('songs').toArray();
const playlists = await db.table<LocalPlaylist>('mukkePlaylists').toArray();
const activeSongs = songs.filter((s) => !s.deletedAt);

View file

@ -65,7 +65,7 @@ db.version(1).stores({
zitareLists: 'id',
zitareListTags: 'id, listId, tagId, [listId+tagId]',
// ─── Mukke (appId: 'mukke') ───
// ─── Music (appId: 'music') ───
songs: 'id, artist, album, genre, favorite, title',
mukkePlaylists: 'id, name',
playlistSongs: 'id, playlistId, songId, sortOrder, [playlistId+sortOrder]',
@ -242,6 +242,160 @@ db.version(2)
});
});
// ─── Version 3: Unified Time Model (timeBlocks) ─────────────
// Adds timeBlocks table, updates indexes on events/timeEntries/tasks/habitLogs,
// and migrates existing time data into timeBlocks.
db.version(3)
.stores({
// New tables
timeBlocks:
'id, startDate, kind, type, sourceModule, sourceId, [sourceModule+sourceId], [type+startDate], [kind+startDate]',
timeBlockTags: 'id, blockId, tagId, [blockId+tagId]',
// Updated indexes (timeBlockId / scheduledBlockId added)
events: 'id, calendarId, timeBlockId',
timeEntries: 'id, projectId, clientId, timeBlockId, guildId, visibility',
tasks:
'id, dueDate, isCompleted, priority, order, projectId, scheduledBlockId, [isCompleted+order], [projectId+order]',
habitLogs: 'id, habitId, timeBlockId, [habitId+timeBlockId]',
})
.upgrade(async (tx) => {
const timeBlocksTable = tx.table('timeBlocks');
// 1. Migrate calendar events → timeBlocks
const events = await tx.table('events').toArray();
for (const event of events) {
if (!event.startDate) continue;
const blockId = crypto.randomUUID();
await timeBlocksTable.add({
id: blockId,
startDate: event.startDate,
endDate: event.endDate ?? null,
allDay: event.allDay ?? false,
isLive: false,
timezone: null,
recurrenceRule: event.recurrenceRule ?? null,
kind: 'scheduled',
type: 'event',
sourceModule: 'calendar',
sourceId: event.id,
linkedBlockId: null,
title: event.title ?? '',
description: event.description ?? null,
color: event.color ?? null,
icon: null,
projectId: null,
createdAt: event.createdAt ?? new Date().toISOString(),
updatedAt: event.updatedAt ?? new Date().toISOString(),
deletedAt: event.deletedAt ?? null,
});
await tx.table('events').update(event.id, { timeBlockId: blockId });
}
// 2. Migrate time entries → timeBlocks
const entries = await tx.table('timeEntries').toArray();
for (const entry of entries) {
if (!entry.date && !entry.startTime) continue; // skip entries with no date at all
const blockId = crypto.randomUUID();
const startDate = entry.startTime ?? `${entry.date}T00:00:00.000Z`;
await timeBlocksTable.add({
id: blockId,
startDate,
endDate: entry.endTime ?? null,
allDay: false,
isLive: entry.isRunning ?? false,
timezone: null,
recurrenceRule: null,
kind: 'logged',
type: 'timeEntry',
sourceModule: 'times',
sourceId: entry.id,
linkedBlockId: null,
title: entry.description || 'Time Entry',
description: null,
color: null,
icon: null,
projectId: entry.projectId ?? null,
createdAt: entry.createdAt ?? new Date().toISOString(),
updatedAt: entry.updatedAt ?? new Date().toISOString(),
deletedAt: entry.deletedAt ?? null,
});
await tx.table('timeEntries').update(entry.id, { timeBlockId: blockId });
}
// 3. Migrate habit logs → timeBlocks
const logs = await tx.table('habitLogs').toArray();
const habitsById = new Map<string, Record<string, unknown>>();
const allHabits = await tx.table('habits').toArray();
for (const h of allHabits) habitsById.set(h.id as string, h);
for (const log of logs) {
if (!log.timestamp) continue;
const blockId = crypto.randomUUID();
const habit = habitsById.get(log.habitId as string);
await timeBlocksTable.add({
id: blockId,
startDate: log.timestamp,
endDate: null,
allDay: false,
isLive: false,
timezone: null,
recurrenceRule: null,
kind: 'logged',
type: 'habit',
sourceModule: 'habits',
sourceId: log.id,
linkedBlockId: null,
title: (habit?.title as string) ?? 'Habit',
description: null,
color: (habit?.color as string) ?? null,
icon: (habit?.icon as string) ?? null,
projectId: null,
createdAt: log.createdAt ?? new Date().toISOString(),
updatedAt: log.updatedAt ?? log.createdAt ?? new Date().toISOString(),
deletedAt: log.deletedAt ?? null,
});
await tx.table('habitLogs').update(log.id, { timeBlockId: blockId });
}
// 4. Migrate scheduled tasks → timeBlocks
const tasks = await tx.table('tasks').toArray();
for (const task of tasks) {
if (!task.scheduledDate) continue;
const blockId = crypto.randomUUID();
const startISO = task.scheduledStartTime
? `${task.scheduledDate}T${task.scheduledStartTime}:00`
: `${task.scheduledDate}T09:00:00`;
const durationMs = task.estimatedDuration ? task.estimatedDuration * 1000 : 3600000; // default 1h
const endISO = new Date(new Date(startISO).getTime() + durationMs).toISOString();
await timeBlocksTable.add({
id: blockId,
startDate: startISO,
endDate: endISO,
allDay: !task.scheduledStartTime,
isLive: false,
timezone: null,
recurrenceRule: null,
kind: 'scheduled',
type: 'task',
sourceModule: 'todo',
sourceId: task.id,
linkedBlockId: null,
title: task.title ?? '',
description: null,
color: null,
icon: null,
projectId: task.projectId ?? null,
createdAt: task.createdAt ?? new Date().toISOString(),
updatedAt: task.updatedAt ?? new Date().toISOString(),
deletedAt: task.deletedAt ?? null,
});
await tx.table('tasks').update(task.id, { scheduledBlockId: blockId });
}
});
// ─── Sync App Map ──────────────────────────────────────────
// Maps each table to its appId for sync routing.
// The SyncEngine uses this to group pending changes and push to /sync/{appId}.
@ -255,7 +409,7 @@ export const SYNC_APP_MAP: Record<string, string[]> = {
picture: ['images', 'boards', 'boardItems', 'imageTags'],
cards: ['cardDecks', 'cards', 'deckTags'],
zitare: ['zitareFavorites', 'zitareLists', 'zitareListTags'],
mukke: ['songs', 'mukkePlaylists', 'playlistSongs', 'mukkeProjects', 'markers', 'songTags'],
music: ['songs', 'mukkePlaylists', 'playlistSongs', 'mukkeProjects', 'markers', 'songTags'],
storage: ['files', 'storageFolders', 'fileTags'],
presi: ['presiDecks', 'slides', 'presiDeckTags'],
inventar: ['invCollections', 'invItems', 'invLocations', 'invCategories', 'invItemTags'],
@ -288,6 +442,7 @@ export const SYNC_APP_MAP: Record<string, string[]> = {
places: ['places', 'locationLogs', 'placeTags'],
tags: ['globalTags', 'tagGroups'],
links: ['manaLinks'],
timeblocks: ['timeBlocks', 'timeBlockTags'],
};
// ─── Reverse Map: Table → AppId ────────────────────────────
@ -313,7 +468,7 @@ export const TABLE_TO_SYNC_NAME: Record<string, string> = {
// zitare
zitareFavorites: 'favorites',
zitareLists: 'lists',
// mukke
// music
mukkePlaylists: 'playlists',
mukkeProjects: 'projects',
// storage

View file

@ -0,0 +1,87 @@
/**
* TimeBlock module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalTimeBlock, LocalTimeBlockTag } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const timeBlockTable = db.table<LocalTimeBlock>('timeBlocks');
export const timeBlockTagTable = db.table<LocalTimeBlockTag>('timeBlockTags');
// ─── Guest Seed ────────────────────────────────────────────
export const TIME_BLOCK_GUEST_SEED = {
timeBlocks: (() => {
const now = new Date();
const nowISO = now.toISOString();
const today10 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 10, 0, 0);
const today11 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 11, 0, 0);
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrow14 = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
14,
0,
0
);
const tomorrow15 = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
15,
30,
0
);
return [
{
id: 'sample-tb-event-1',
startDate: today10.toISOString(),
endDate: today11.toISOString(),
allDay: false,
isLive: false,
timezone: null,
recurrenceRule: null,
kind: 'scheduled' as const,
type: 'event' as const,
sourceModule: 'calendar' as const,
sourceId: 'sample-event-1',
linkedBlockId: null,
title: 'Willkommen bei Kalender!',
description:
'Dies ist ein Beispieltermin. Tippe darauf, um ihn zu bearbeiten oder zu löschen.',
color: null,
icon: null,
projectId: null,
createdAt: nowISO,
updatedAt: nowISO,
},
{
id: 'sample-tb-event-2',
startDate: tomorrow14.toISOString(),
endDate: tomorrow15.toISOString(),
allDay: false,
isLive: false,
timezone: null,
recurrenceRule: null,
kind: 'scheduled' as const,
type: 'event' as const,
sourceModule: 'calendar' as const,
sourceId: 'sample-event-2',
linkedBlockId: null,
title: 'Mittagessen mit Freunden',
description: null,
color: null,
icon: null,
projectId: null,
createdAt: nowISO,
updatedAt: nowISO,
},
] satisfies LocalTimeBlock[];
})(),
timeBlockTags: [] as LocalTimeBlockTag[],
};

View file

@ -0,0 +1,4 @@
export * from './types';
export * from './collections';
export * from './service';
export * from './queries';

View file

@ -0,0 +1,206 @@
/**
* Reactive Queries & Pure Helpers for TimeBlocks.
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes.
* Components call these hooks at init time; no manual fetch/refresh needed.
*
* Note: useLiveQueryWithDefault takes (querier, default) no deps array.
* For parameterized queries, use raw liveQuery from Dexie instead.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type {
LocalTimeBlock,
TimeBlock,
TimeBlockKind,
TimeBlockType,
TimeBlockSourceModule,
} from './types';
import { isSameDay, isWithinInterval } from 'date-fns';
// ─── Type Converter ──────────────────────────────────────
export function toTimeBlock(local: LocalTimeBlock): TimeBlock {
return {
id: local.id,
startDate: local.startDate,
endDate: local.endDate ?? null,
allDay: local.allDay,
isLive: local.isLive,
timezone: local.timezone ?? null,
recurrenceRule: local.recurrenceRule ?? null,
kind: local.kind,
type: local.type,
sourceModule: local.sourceModule,
sourceId: local.sourceId,
linkedBlockId: local.linkedBlockId ?? null,
title: local.title,
description: local.description ?? null,
color: local.color ?? null,
icon: local.icon ?? null,
projectId: local.projectId ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Svelte 5 Reactive Hooks ─────────────────────────────
/** All non-deleted timeBlocks. Auto-updates on change. */
export function useAllTimeBlocks() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
}, [] as TimeBlock[]);
}
/**
* All non-deleted timeBlocks within a date range.
* Returns a raw Dexie liveQuery observable (use with $-subscribe in Svelte).
*/
export function timeBlocksInRange$(start: string, end: string) {
return liveQuery(async () => {
const locals = await db
.table<LocalTimeBlock>('timeBlocks')
.where('startDate')
.between(start, end, true, true)
.toArray();
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
});
}
/** TimeBlock(s) for a specific source record (raw observable). */
export function timeBlocksBySource$(sourceModule: TimeBlockSourceModule, sourceId: string) {
return liveQuery(async () => {
const locals = await db
.table<LocalTimeBlock>('timeBlocks')
.where('[sourceModule+sourceId]')
.equals([sourceModule, sourceId])
.toArray();
return locals.filter((b) => !b.deletedAt).map(toTimeBlock);
});
}
/** The currently live/running timeBlock (if any). */
export function useLiveTimeBlock() {
return useLiveQueryWithDefault(
async () => {
// Can't index boolean in Dexie reliably, so scan and filter
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const active = locals.find((b) => b.isLive && !b.deletedAt);
return active ? toTimeBlock(active) : null;
},
null as TimeBlock | null
);
}
// ─── Pure Helpers ─────────────────────────────────────────
/** Convert a date string or Date to a Date. */
function toDate(dateStr: string | Date): Date {
return typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
}
/** Get timeBlocks for a specific day. */
export function getBlocksForDay(blocks: TimeBlock[], date: Date): TimeBlock[] {
return blocks.filter((block) => {
const blockStart = toDate(block.startDate);
const blockEnd = block.endDate ? toDate(block.endDate) : blockStart;
if (block.allDay) {
return (
isWithinInterval(date, { start: blockStart, end: blockEnd }) || isSameDay(date, blockStart)
);
}
// Point events: match day of startDate
if (!block.endDate) {
return isSameDay(date, blockStart);
}
// Range events: any overlap with the day
const dayStart = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 0, 0, 0);
const dayEnd = new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999);
return blockStart <= dayEnd && blockEnd >= dayStart;
});
}
/** Get timeBlocks within a time range. */
export function getBlocksInRange(blocks: TimeBlock[], start: Date, end: Date): TimeBlock[] {
return blocks.filter((block) => {
const blockStart = toDate(block.startDate);
const blockEnd = block.endDate ? toDate(block.endDate) : blockStart;
return blockStart <= end && blockEnd >= start;
});
}
/** Sort timeBlocks by start time. */
export function sortBlocksByTime(blocks: TimeBlock[]): TimeBlock[] {
return [...blocks].sort(
(a, b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
);
}
/** Filter timeBlocks by kind. */
export function filterBlocksByKind(blocks: TimeBlock[], kind: TimeBlockKind): TimeBlock[] {
return blocks.filter((b) => b.kind === kind);
}
/** Filter timeBlocks by type. */
export function filterBlocksByType(blocks: TimeBlock[], type: TimeBlockType): TimeBlock[] {
return blocks.filter((b) => b.type === type);
}
/** Filter timeBlocks by visible types (for calendar filter toggles). */
export function filterBlocksByVisibleTypes(
blocks: TimeBlock[],
visibleTypes: Set<TimeBlockType>
): TimeBlock[] {
return blocks.filter((b) => visibleTypes.has(b.type));
}
/** Find overlapping timeBlocks for a given range. */
export function findOverlaps(
blocks: TimeBlock[],
start: string,
end: string,
excludeId?: string
): TimeBlock[] {
const rangeStart = new Date(start);
const rangeEnd = new Date(end);
return blocks.filter((block) => {
if (block.id === excludeId) return false;
if (block.allDay) return false;
const blockStart = new Date(block.startDate);
const blockEnd = block.endDate ? new Date(block.endDate) : blockStart;
return blockStart < rangeEnd && blockEnd > rangeStart;
});
}
/** Get the raw wall-clock duration in seconds (derived from start/end). */
export function getBlockDuration(block: TimeBlock): number {
if (!block.endDate) return 0;
return Math.max(
0,
(new Date(block.endDate).getTime() - new Date(block.startDate).getTime()) / 1000
);
}
/** Group timeBlocks by date string (YYYY-MM-DD). */
export function groupBlocksByDate(blocks: TimeBlock[]): Map<string, TimeBlock[]> {
const map = new Map<string, TimeBlock[]>();
for (const block of blocks) {
const dateKey = block.startDate.split('T')[0];
const group = map.get(dateKey);
if (group) {
group.push(block);
} else {
map.set(dateKey, [block]);
}
}
return map;
}

View file

@ -0,0 +1,75 @@
/**
* TimeBlock Service shared CRUD helper called by all module stores.
*
* Module stores create both their domain record and a timeBlock in the same
* Dexie transaction to keep them consistent.
*/
import { db } from '$lib/data/database';
import { timeBlockTable } from './collections';
import type { LocalTimeBlock, CreateTimeBlockInput, UpdateTimeBlockInput } from './types';
/** Create a new timeBlock and return its ID. */
export async function createBlock(input: CreateTimeBlockInput): Promise<string> {
const now = new Date().toISOString();
const id = crypto.randomUUID();
const block: LocalTimeBlock = {
id,
startDate: input.startDate,
endDate: input.endDate ?? null,
allDay: input.allDay ?? false,
isLive: input.isLive ?? false,
timezone: input.timezone ?? null,
recurrenceRule: input.recurrenceRule ?? null,
kind: input.kind,
type: input.type,
sourceModule: input.sourceModule,
sourceId: input.sourceId,
linkedBlockId: input.linkedBlockId ?? null,
title: input.title,
description: input.description ?? null,
color: input.color ?? null,
icon: input.icon ?? null,
projectId: input.projectId ?? null,
createdAt: now,
updatedAt: now,
};
await timeBlockTable.add(block);
return id;
}
/** Update an existing timeBlock. */
export async function updateBlock(id: string, input: UpdateTimeBlockInput): Promise<void> {
const now = new Date().toISOString();
await timeBlockTable.update(id, {
...input,
updatedAt: now,
});
}
/** Soft-delete a timeBlock. */
export async function deleteBlock(id: string): Promise<void> {
const now = new Date().toISOString();
await timeBlockTable.update(id, {
deletedAt: now,
updatedAt: now,
});
}
/** Link a scheduled block to a logged block (plan vs. reality). */
export async function linkBlocks(scheduledId: string, loggedId: string): Promise<void> {
const now = new Date().toISOString();
await db.transaction('rw', timeBlockTable, async () => {
await timeBlockTable.update(scheduledId, { linkedBlockId: loggedId, updatedAt: now });
await timeBlockTable.update(loggedId, { linkedBlockId: scheduledId, updatedAt: now });
});
}
/** Get a single timeBlock by ID. */
export async function getBlock(id: string): Promise<LocalTimeBlock | undefined> {
const block = await timeBlockTable.get(id);
if (block?.deletedAt) return undefined;
return block;
}

View file

@ -0,0 +1,110 @@
/**
* Unified Time Model TimeBlock types.
*
* A TimeBlock represents any time interval across all modules.
* Domain-specific data stays on each module's tables; the TimeBlock
* owns the time dimension (start, end, recurrence, live status).
*/
import type { BaseRecord } from '@manacore/local-store';
// ─── Enums ───────────────────────────────────────────────
export type TimeBlockKind = 'scheduled' | 'logged';
export type TimeBlockType = 'event' | 'task' | 'habit' | 'timeEntry' | 'focus' | 'break';
export type TimeBlockSourceModule = 'calendar' | 'todo' | 'times' | 'habits';
// ─── Local Record Types (Dexie) ──────────────────────────
export interface LocalTimeBlock extends BaseRecord {
// Time
startDate: string; // ISO — always set
endDate: string | null; // ISO — null = point-event or live timer
allDay: boolean;
isLive: boolean; // timer/tracking currently running
timezone?: string | null;
recurrenceRule?: string | null;
// Classification
kind: TimeBlockKind;
type: TimeBlockType;
// Link to source module
sourceModule: TimeBlockSourceModule;
sourceId: string;
linkedBlockId?: string | null; // scheduled → logged link
// Display (denormalized for calendar rendering without joins)
title: string;
description?: string | null;
color?: string | null;
icon?: string | null;
projectId?: string | null;
}
export interface LocalTimeBlockTag extends BaseRecord {
blockId: string;
tagId: string;
}
// ─── Domain Types (returned by queries, used by UI) ──────
export interface TimeBlock {
id: string;
startDate: string;
endDate: string | null;
allDay: boolean;
isLive: boolean;
timezone: string | null;
recurrenceRule: string | null;
kind: TimeBlockKind;
type: TimeBlockType;
sourceModule: TimeBlockSourceModule;
sourceId: string;
linkedBlockId: string | null;
title: string;
description: string | null;
color: string | null;
icon: string | null;
projectId: string | null;
createdAt: string;
updatedAt: string;
}
// ─── Input Types ─────────────────────────────────────────
export interface CreateTimeBlockInput {
startDate: string;
endDate?: string | null;
allDay?: boolean;
isLive?: boolean;
timezone?: string | null;
recurrenceRule?: string | null;
kind: TimeBlockKind;
type: TimeBlockType;
sourceModule: TimeBlockSourceModule;
sourceId: string;
linkedBlockId?: string | null;
title: string;
description?: string | null;
color?: string | null;
icon?: string | null;
projectId?: string | null;
}
export interface UpdateTimeBlockInput {
startDate?: string;
endDate?: string | null;
allDay?: boolean;
isLive?: boolean;
timezone?: string | null;
recurrenceRule?: string | null;
linkedBlockId?: string | null;
title?: string;
description?: string | null;
color?: string | null;
icon?: string | null;
projectId?: string | null;
}

View file

@ -4,10 +4,10 @@
Clicking an event opens the detail view.
-->
<script lang="ts">
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalEvent } from './types';
import { eventsStore } from './stores/events.svelte';
import { useAllCalendarItems } from './queries';
import type { CalendarEvent } from './types';
import { Plus, PencilSimple, Trash } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
@ -21,7 +21,7 @@
let allTags = $derived(tagsQuery.value ?? []);
function handleTagDrop(eventId: string, tagData: TagDragData) {
const event = events.find((e) => e.id === eventId);
const event = allItems.find((e) => e.id === eventId);
if (!event) return;
const current = event.tagIds ?? [];
if (!current.includes(tagData.id)) {
@ -29,13 +29,8 @@
}
}
let events$ = useLiveQueryWithDefault(async () => {
return db
.table<LocalEvent>('events')
.toArray()
.then((all) => all.filter((e) => !e.deletedAt));
}, [] as LocalEvent[]);
let events = $derived(events$.value);
const itemsQuery = useAllCalendarItems();
let allItems = $derived(itemsQuery.value ?? []);
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
@ -53,9 +48,9 @@
});
const todayEvents = $derived(
events
.filter((e) => e.startDate.startsWith(todayStr))
.sort((a, b) => a.startDate.localeCompare(b.startDate))
allItems
.filter((e) => e.startTime.startsWith(todayStr))
.sort((a, b) => a.startTime.localeCompare(b.startTime))
);
function formatTime(iso: string): string {
@ -65,14 +60,14 @@
const dayNames = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
// Context menu
let ctxMenu = $state<{ visible: boolean; x: number; y: number; event: LocalEvent | null }>({
let ctxMenu = $state<{ visible: boolean; x: number; y: number; event: CalendarEvent | null }>({
visible: false,
x: 0,
y: 0,
event: null,
});
function handleItemContextMenu(e: MouseEvent, event: LocalEvent) {
function handleItemContextMenu(e: MouseEvent, event: CalendarEvent) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, event };
}
@ -140,8 +135,8 @@
<div class="week-strip">
{#each weekDays() as day, i}
{@const isToday = day.toISOString().split('T')[0] === todayStr}
{@const dayEvents = events.filter((e) =>
e.startDate.startsWith(day.toISOString().split('T')[0])
{@const dayEvents = allItems.filter((e) =>
e.startTime.startsWith(day.toISOString().split('T')[0])
)}
<div class="day-col">
<span class="day-name">{dayNames[i]}</span>
@ -188,8 +183,8 @@
data: () => ({
id: event.id,
title: event.title,
startDate: event.startDate,
endDate: event.endDate,
startDate: event.startTime,
endDate: event.endTime,
description: event.description,
location: event.location,
}),
@ -214,10 +209,10 @@
{/if}
</div>
<p class="event-time-label">
{#if event.allDay}
{#if event.isAllDay}
Ganztägig
{:else}
{formatTime(event.startDate)}{formatTime(event.endDate)}
{formatTime(event.startTime)}{formatTime(event.endTime)}
{/if}
</p>
{#if event.location}

View file

@ -1,5 +1,8 @@
/**
* Calendar module collection accessors and guest seed data.
*
* Events no longer store time fields directly those live on TimeBlocks.
* Guest seed creates both TimeBlock + LocalEvent pairs.
*/
import { db } from '$lib/data/database';
@ -25,57 +28,27 @@ export const CALENDAR_GUEST_SEED = {
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
},
] satisfies LocalCalendar[],
events: (() => {
const now = new Date();
const today10 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 10, 0, 0);
const today11 = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 11, 0, 0);
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrow14 = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
14,
0,
0
);
const tomorrow15 = new Date(
tomorrow.getFullYear(),
tomorrow.getMonth(),
tomorrow.getDate(),
15,
30,
0
);
return [
{
id: 'sample-event-1',
calendarId: PERSONAL_CALENDAR_ID,
title: 'Willkommen bei Kalender!',
description:
'Dies ist ein Beispieltermin. Tippe darauf, um ihn zu bearbeiten oder zu löschen.',
startDate: today10.toISOString(),
endDate: today11.toISOString(),
allDay: false,
location: null,
recurrenceRule: null,
color: null,
reminders: null,
},
{
id: 'sample-event-2',
calendarId: PERSONAL_CALENDAR_ID,
title: 'Mittagessen mit Freunden',
description: null,
startDate: tomorrow14.toISOString(),
endDate: tomorrow15.toISOString(),
allDay: false,
location: 'Café am See',
recurrenceRule: null,
color: null,
reminders: null,
},
] satisfies LocalEvent[];
})(),
events: [
{
id: 'sample-event-1',
calendarId: PERSONAL_CALENDAR_ID,
timeBlockId: 'sample-tb-event-1',
title: 'Willkommen bei Kalender!',
description:
'Dies ist ein Beispieltermin. Tippe darauf, um ihn zu bearbeiten oder zu löschen.',
location: null,
color: null,
reminders: null,
},
{
id: 'sample-event-2',
calendarId: PERSONAL_CALENDAR_ID,
timeBlockId: 'sample-tb-event-2',
title: 'Mittagessen mit Freunden',
description: null,
location: 'Café am See',
color: null,
reminders: null,
},
] satisfies LocalEvent[],
};

View file

@ -24,14 +24,23 @@
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('calendarEvents');
let rangeEvents = $derived.by(() => {
const visible = filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value);
const filtered = filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value).filter(
(e) => calendarViewStore.visibleBlockTypes.has(e.blockType)
);
return getEventsInRange(
visible,
filtered,
calendarViewStore.currentDate,
addMonths(calendarViewStore.currentDate, 3)
);
});
function getItemColor(event: CalendarEvent): string {
if (event.calendarId !== '__external__') {
return getCalendarColor(calendarsCtx.value, event.calendarId);
}
return event.color || '#6b7280';
}
let groupedEvents = $derived.by(() => {
const currentEvents = rangeEvents ?? [];
if (!Array.isArray(currentEvents)) return [];
@ -113,10 +122,7 @@
{#each group.events as event}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="event-item">
<div
class="color-bar"
style="background-color: {getCalendarColor(calendarsCtx.value, event.calendarId)}"
></div>
<div class="color-bar" style="background-color: {getItemColor(event)}"></div>
<div class="event-content">
<div class="event-time">
{#if event.isAllDay}

View file

@ -35,8 +35,16 @@
let showDeleteOptions = $state(false);
let copied = $state(false);
let calendarName = $derived(getCalendarById(calendarsCtx.value, event.calendarId)?.name);
let calendarColor = $derived(getCalendarColor(calendarsCtx.value, event.calendarId));
let calendarName = $derived(
event.calendarId === '__external__'
? event.sourceModule
: getCalendarById(calendarsCtx.value, event.calendarId)?.name
);
let calendarColor = $derived(
event.calendarId === '__external__'
? event.color || '#6b7280'
: getCalendarColor(calendarsCtx.value, event.calendarId)
);
let isRecurring = $derived(!!event.recurrenceRule);
let hasParent = $derived(!!event.parentEventId);

View file

@ -49,14 +49,23 @@
let rangeEvents = $derived.by(() => {
if (allCalendarDays.length === 0) return [];
const visible = filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value);
const filtered = filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value).filter(
(e) => calendarViewStore.visibleBlockTypes.has(e.blockType)
);
return getEventsInRange(
visible,
filtered,
allCalendarDays[0],
allCalendarDays[allCalendarDays.length - 1]
);
});
function getItemColor(event: CalendarEvent): string {
if (event.calendarId !== '__external__') {
return getCalendarColor(calendarsCtx.value, event.calendarId);
}
return event.color || '#6b7280';
}
const weekDays = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
let weeks = $derived.by(() => {
@ -224,7 +233,7 @@
class="event-pill"
class:dragging={isBeingDragged}
data-event-id={event.id}
style="background-color: {getCalendarColor(calendarsCtx.value, event.calendarId)}"
style="background-color: {getItemColor(event)}"
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => handleEventClick(event, e)}
role="button"

View file

@ -10,6 +10,7 @@
getDefaultCalendar,
} from '../queries';
import type { Calendar, CalendarEvent } from '../types';
import { calendarViewStore } from '../stores/view.svelte';
import {
useVisibleHours,
useCurrentTimeIndicator,
@ -41,7 +42,20 @@
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('calendarEvents');
let visibleEvents = $derived(filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value));
let filteredByCalendar = $derived(
filterEventsByVisibleCalendars(eventsCtx.value, calendarsCtx.value)
);
let visibleEvents = $derived(
filteredByCalendar.filter((e) => calendarViewStore.visibleBlockTypes.has(e.blockType))
);
/** Resolve color: calendar color for native events, block color for external items. */
function getItemColor(event: CalendarEvent): string {
if (event.calendarId !== '__external__') {
return getItemColor(event);
}
return event.color || '#6b7280';
}
let viewRange = $derived({
start: startOfWeek(calendarViewStore.currentDate, { weekStartsOn: 1 }),
@ -192,7 +206,7 @@
{#each getAllDayEventsForDay(day) as event}
<button
class="all-day-event"
style="background-color: {getCalendarColor(calendarsCtx.value, event.calendarId)}"
style="background-color: {getItemColor(event)}"
onclick={() => onEventClick?.(event)}
aria-label="{event.title} - Ganztägig"
>
@ -254,7 +268,7 @@
: isBeingResized
? `top: ${eventDragDrop.resizePreviewTop}%; height: ${eventDragDrop.resizePreviewHeight}%;`
: getEventStyle(event)}
color={getCalendarColor(calendarsCtx.value, event.calendarId)}
color={getItemColor(event)}
isDragging={isBeingDragged && !isCrossDayDrag}
isDraggingSource={isCrossDayDrag}
isResizing={isBeingResized}
@ -272,7 +286,7 @@
<EventCard
event={eventDragDrop.draggedEvent}
style="top: {eventDragDrop.dragPreviewTop}%; height: {eventDragDrop.dragPreviewHeight}%;"
color={getCalendarColor(calendarsCtx.value, eventDragDrop.draggedEvent.calendarId)}
color={getItemColor(eventDragDrop.draggedEvent)}
isDragging={true}
formattedTime={formatEventTimeRange(eventDragDrop.draggedEvent)}
/>

View file

@ -7,11 +7,9 @@ export { eventsStore } from './stores/events.svelte';
export { calendarViewStore } from './stores/view.svelte';
export {
useAllCalendars,
useAllCalendarItems,
useAllEvents,
allCalendars$,
allEvents$,
toCalendar,
toCalendarEvent,
getVisibleCalendars,
getDefaultCalendar,
getCalendarById,
@ -23,4 +21,5 @@ export {
sortEventsByTime,
} from './queries';
export { calendarTable, eventTable, CALENDAR_GUEST_SEED } from './collections';
export { timeBlockToCalendarEvent } from './types';
export type { LocalCalendar, LocalEvent, CalendarViewType, CalendarEvent, Calendar } from './types';

View file

@ -1,16 +1,22 @@
/**
* Reactive Queries & Pure Helpers for Calendar module.
*
* The calendar is now a universal time view: it queries timeBlocks (which contain
* events, tasks, habits, time entries) and joins with LocalEvent for native
* calendar events.
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { liveQuery } from 'dexie';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalCalendar, LocalEvent, Calendar, CalendarEvent } from './types';
import { isSameDay, isWithinInterval, differenceInMilliseconds, format } from 'date-fns';
import { timeBlockToCalendarEvent } from './types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { toTimeBlock } from '$lib/data/time-blocks/queries';
import { isSameDay, isWithinInterval } from 'date-fns';
// ─── Type Converters ───────────────────────────────────────
@ -27,42 +33,6 @@ export function toCalendar(local: LocalCalendar): Calendar {
};
}
export function toCalendarEvent(local: LocalEvent): CalendarEvent {
return {
id: local.id,
calendarId: local.calendarId,
title: local.title,
description: local.description ?? null,
location: local.location ?? null,
startTime: local.startDate,
endTime: local.endDate,
isAllDay: local.allDay,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
recurrenceRule: local.recurrenceRule ?? null,
parentEventId: null,
color: local.color ?? null,
tagIds: local.tagIds ?? [],
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Raw Observable Queries (for Svelte $ auto-subscribe) ──
export function allCalendars$() {
return liveQuery(async () => {
const locals = await db.table<LocalCalendar>('calendars').toArray();
return locals.filter((c) => !c.deletedAt).map(toCalendar);
});
}
export function allEvents$() {
return liveQuery(async () => {
const locals = await db.table<LocalEvent>('events').toArray();
return locals.filter((e) => !e.deletedAt).map(toCalendarEvent);
});
}
// ─── Svelte 5 Reactive Hooks (call during component init) ──
/** All calendars, auto-updates on any change. */
@ -73,11 +43,56 @@ export function useAllCalendars() {
}, [] as Calendar[]);
}
/** All events, auto-updates on any change. */
/**
* All calendar items (universal view) queries timeBlocks and joins
* with LocalEvent for native calendar events. Auto-updates on change.
*/
export function useAllCalendarItems() {
return useLiveQueryWithDefault(async () => {
// Fetch all non-deleted timeBlocks
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const activeBlocks = blocks.filter((b) => !b.deletedAt);
// Fetch all non-deleted events for joining with calendar-type blocks
const events = await db.table<LocalEvent>('events').toArray();
const eventsById = new Map<string, LocalEvent>();
for (const e of events) {
if (!e.deletedAt) eventsById.set(e.id, e);
}
// Convert to CalendarEvent, joining event data for calendar blocks
return activeBlocks.map((block) => {
const tb = toTimeBlock(block);
const eventData =
block.sourceModule === 'calendar' ? (eventsById.get(block.sourceId) ?? null) : null;
return timeBlockToCalendarEvent(tb, eventData);
});
}, [] as CalendarEvent[]);
}
/**
* Only native calendar events (for backward compatibility with calendar-specific views).
*/
export function useAllEvents() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalEvent>('events').toArray();
return locals.filter((e) => !e.deletedAt).map(toCalendarEvent);
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const calendarBlocks = blocks.filter(
(b) => !b.deletedAt && b.sourceModule === 'calendar' && b.type === 'event'
);
const events = await db.table<LocalEvent>('events').toArray();
const eventsById = new Map<string, LocalEvent>();
for (const e of events) {
if (!e.deletedAt) eventsById.set(e.id, e);
}
return calendarBlocks
.map((block) => {
const tb = toTimeBlock(block);
const eventData = eventsById.get(block.sourceId) ?? null;
return timeBlockToCalendarEvent(tb, eventData);
})
.filter((e) => e.calendarId !== '__external__');
}, [] as CalendarEvent[]);
}
@ -147,13 +162,14 @@ export function getEventsInRange(events: CalendarEvent[], start: Date, end: Date
/**
* Filter events by visible calendars.
* External (non-calendar) items pass through the filter.
*/
export function filterEventsByVisibleCalendars(
events: CalendarEvent[],
calendars: Calendar[]
): CalendarEvent[] {
const visibleIds = new Set(calendars.filter((c) => c.isVisible).map((c) => c.id));
return events.filter((e) => visibleIds.has(e.calendarId));
return events.filter((e) => e.calendarId === '__external__' || visibleIds.has(e.calendarId));
}
/**

View file

@ -6,9 +6,10 @@ import type { InputBarAdapter } from '$lib/quick-input/types';
import type { QuickInputItem } from '@manacore/shared-ui';
import { db } from '$lib/data/database';
import { parseEventInput, resolveEventIds, formatParsedEventPreview } from './utils/event-parser';
import { toCalendar, toCalendarEvent } from './queries';
import { toCalendar } from './queries';
import type { LocalCalendar, LocalEvent } from './types';
import { format, isSameDay } from 'date-fns';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { format } from 'date-fns';
import { de } from 'date-fns/locale';
export function createAdapter(): InputBarAdapter {
@ -21,22 +22,28 @@ export function createAdapter(): InputBarAdapter {
async onSearch(query) {
const q = query.toLowerCase();
const events = await db.table<LocalEvent>('events').toArray();
return events
.filter((e) => !e.deletedAt && e.title?.toLowerCase().includes(q))
// Search timeBlocks of type 'event' for calendar events
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
return blocks
.filter(
(b) =>
!b.deletedAt &&
b.sourceModule === 'calendar' &&
b.type === 'event' &&
b.title?.toLowerCase().includes(q)
)
.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime())
.slice(0, 10)
.map((e) => ({
id: e.id,
title: e.title || '',
subtitle: e.startDate
? format(new Date(e.startDate), 'dd. MMM yyyy, HH:mm', { locale: de })
.map((b) => ({
id: b.sourceId, // event ID
title: b.title || '',
subtitle: b.startDate
? format(new Date(b.startDate), 'dd. MMM yyyy, HH:mm', { locale: de })
: '',
}));
},
onSelect(item: QuickInputItem) {
// Could open event detail modal — for now just navigate
window.dispatchEvent(new CustomEvent('calendar:open-event', { detail: { id: item.id } }));
},

View file

@ -1,14 +1,16 @@
/**
* Events Store Mutation-Only Service
*
* Creates both a TimeBlock (time dimension) and a LocalEvent (domain data)
* for each calendar event. Updates route time changes to the TimeBlock and
* domain changes to the LocalEvent.
*
* All reads are handled by liveQuery hooks in queries.ts.
* This store only provides write operations and draft event state.
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { db } from '$lib/data/database';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import type { LocalEvent, CalendarEvent } from '../types';
import { toCalendarEvent } from '../queries';
import { CalendarEvents } from '@manacore/shared-utils/analytics';
let error = $state<string | null>(null);
@ -23,7 +25,7 @@ export const eventsStore = {
},
/**
* Create a new event -- writes to IndexedDB instantly.
* Create a new event creates TimeBlock + LocalEvent in IndexedDB.
*/
async createEvent(input: {
calendarId: string;
@ -38,16 +40,31 @@ export const eventsStore = {
}) {
error = null;
try {
const newLocal: LocalEvent = {
id: crypto.randomUUID(),
calendarId: input.calendarId,
title: input.title,
description: input.description ?? null,
const eventId = crypto.randomUUID();
// 1. Create TimeBlock (owns time dimension)
const timeBlockId = await createBlock({
startDate: input.startTime,
endDate: input.endTime,
allDay: input.isAllDay ?? false,
location: input.location ?? null,
recurrenceRule: input.recurrenceRule ?? null,
kind: 'scheduled',
type: 'event',
sourceModule: 'calendar',
sourceId: eventId,
title: input.title,
description: input.description ?? null,
color: input.color ?? null,
});
// 2. Create LocalEvent (domain data)
const newLocal: LocalEvent = {
id: eventId,
calendarId: input.calendarId,
timeBlockId,
title: input.title,
description: input.description ?? null,
location: input.location ?? null,
color: input.color ?? null,
reminders: null,
createdAt: new Date().toISOString(),
@ -56,7 +73,7 @@ export const eventsStore = {
await db.table<LocalEvent>('events').add(newLocal);
CalendarEvents.eventCreated(!!input.recurrenceRule);
return { success: true, data: toCalendarEvent(newLocal) };
return { success: true, data: { id: eventId, timeBlockId } };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create event';
return { success: false, error };
@ -64,7 +81,7 @@ export const eventsStore = {
},
/**
* Update an event -- writes to IndexedDB instantly.
* Update an event routes time changes to TimeBlock, domain changes to LocalEvent.
*/
async updateEvent(
id: string,
@ -82,26 +99,37 @@ export const eventsStore = {
) {
error = null;
try {
// Get the event to find its timeBlockId
const event = await db.table<LocalEvent>('events').get(id);
if (!event) return { success: false, error: 'Event not found' };
// Update TimeBlock for time-related fields
const blockUpdates: Record<string, unknown> = {};
if (input.startTime !== undefined) blockUpdates.startDate = input.startTime;
if (input.endTime !== undefined) blockUpdates.endDate = input.endTime;
if (input.isAllDay !== undefined) blockUpdates.allDay = input.isAllDay;
if (input.recurrenceRule !== undefined) blockUpdates.recurrenceRule = input.recurrenceRule;
if (input.title !== undefined) blockUpdates.title = input.title;
if (input.description !== undefined) blockUpdates.description = input.description;
if (input.color !== undefined) blockUpdates.color = input.color;
if (Object.keys(blockUpdates).length > 0) {
await updateBlock(event.timeBlockId, blockUpdates);
}
// Update LocalEvent for domain fields
const localData: Partial<LocalEvent> = {
updatedAt: new Date().toISOString(),
};
if (input.title !== undefined) localData.title = input.title;
if (input.description !== undefined) localData.description = input.description;
if (input.startTime !== undefined) localData.startDate = input.startTime;
if (input.endTime !== undefined) localData.endDate = input.endTime;
if (input.isAllDay !== undefined) localData.allDay = input.isAllDay;
if (input.location !== undefined) localData.location = input.location;
if (input.recurrenceRule !== undefined) localData.recurrenceRule = input.recurrenceRule;
if (input.color !== undefined) localData.color = input.color;
if (input.calendarId !== undefined) localData.calendarId = input.calendarId;
await db.table('events').update(id, localData);
const updated = await db.table<LocalEvent>('events').get(id);
if (updated) {
CalendarEvents.eventUpdated();
return { success: true, data: toCalendarEvent(updated) };
}
return { success: false, error: 'Event not found' };
CalendarEvents.eventUpdated();
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update event';
return { success: false, error };
@ -109,11 +137,16 @@ export const eventsStore = {
},
/**
* Delete an event -- soft-deletes from IndexedDB instantly.
* Delete an event soft-deletes both TimeBlock and LocalEvent.
*/
async deleteEvent(id: string) {
error = null;
try {
const event = await db.table<LocalEvent>('events').get(id);
if (event?.timeBlockId) {
await deleteBlock(event.timeBlockId);
}
await db.table('events').update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@ -142,6 +175,7 @@ export const eventsStore = {
draftEvent = {
id: '__draft__',
calendarId: data.calendarId || '',
timeBlockId: '__draft_block__',
title: data.title || '',
description: data.description || null,
location: data.location || null,
@ -155,6 +189,12 @@ export const eventsStore = {
tagIds: data.tagIds || [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
blockType: 'event',
sourceModule: 'calendar',
sourceId: '__draft__',
icon: null,
isLive: false,
projectId: null,
};
return draftEvent;
},

View file

@ -4,6 +4,7 @@
import { browser } from '$app/environment';
import type { CalendarViewType } from '../types';
import type { TimeBlockType } from '$lib/data/time-blocks/types';
import {
startOfDay,
startOfWeek,
@ -22,6 +23,9 @@ const SUPPORTED_VIEWS: CalendarViewType[] = ['week', 'month', 'agenda'];
let currentDate = $state(new Date());
let viewType = $state<CalendarViewType>('week');
let visibleBlockTypes = $state<Set<TimeBlockType>>(
new Set(['event', 'task', 'habit', 'timeEntry', 'focus', 'break'])
);
const viewRange = $derived.by(() => {
const weekStartsOn = 1 as 0 | 1; // Monday
@ -60,6 +64,9 @@ export const calendarViewStore = {
get viewRange() {
return viewRange;
},
get visibleBlockTypes() {
return visibleBlockTypes;
},
initialize() {
if (!browser) return;
@ -84,6 +91,20 @@ export const calendarViewStore = {
}
},
toggleBlockType(type: TimeBlockType) {
const next = new Set(visibleBlockTypes);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
visibleBlockTypes = next;
},
setVisibleBlockTypes(types: Set<TimeBlockType>) {
visibleBlockTypes = types;
},
goToToday() {
currentDate = new Date();
},

View file

@ -1,8 +1,12 @@
/**
* Calendar module types for the unified ManaCore app.
*
* Time fields (startDate, endDate, allDay, recurrenceRule) live on TimeBlock.
* LocalEvent only stores calendar-domain data + a timeBlockId reference.
*/
import type { BaseRecord } from '@manacore/local-store';
import type { TimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
export interface LocalCalendar extends BaseRecord {
name: string;
@ -14,13 +18,10 @@ export interface LocalCalendar extends BaseRecord {
export interface LocalEvent extends BaseRecord {
calendarId: string;
timeBlockId: string;
title: string;
description?: string | null;
startDate: string;
endDate: string;
allDay: boolean;
location?: string | null;
recurrenceRule?: string | null;
color?: string | null;
reminders?: unknown | null;
tagIds?: string[];
@ -28,9 +29,14 @@ export interface LocalEvent extends BaseRecord {
export type CalendarViewType = 'week' | 'month' | 'agenda';
/**
* CalendarEvent the UI-facing type used by all calendar components.
* Combines LocalEvent domain data with TimeBlock time data.
*/
export interface CalendarEvent {
id: string;
calendarId: string;
timeBlockId: string;
title: string;
description: string | null;
location: string | null;
@ -44,6 +50,13 @@ export interface CalendarEvent {
tagIds: string[];
createdAt: string;
updatedAt: string;
// TimeBlock metadata (for universal calendar view)
blockType: TimeBlockType;
sourceModule: string;
sourceId: string;
icon: string | null;
isLive: boolean;
projectId: string | null;
}
export interface Calendar {
@ -56,3 +69,38 @@ export interface Calendar {
createdAt: string;
updatedAt: string;
}
/**
* Construct a CalendarEvent from a TimeBlock.
* For native calendar events, also merges LocalEvent domain data.
* For cross-module blocks (tasks, habits, time entries), uses TimeBlock display fields.
*/
export function timeBlockToCalendarEvent(
block: TimeBlock,
eventData?: LocalEvent | null
): CalendarEvent {
return {
id: eventData?.id ?? block.sourceId,
calendarId: eventData?.calendarId ?? '__external__',
timeBlockId: block.id,
title: eventData?.title ?? block.title,
description: eventData?.description ?? block.description ?? null,
location: eventData?.location ?? null,
startTime: block.startDate,
endTime: block.endDate ?? block.startDate,
isAllDay: block.allDay,
timezone: block.timezone,
recurrenceRule: block.recurrenceRule,
parentEventId: null,
color: eventData?.color ?? block.color,
tagIds: eventData?.tagIds ?? [],
createdAt: block.createdAt,
updatedAt: block.updatedAt,
blockType: block.type,
sourceModule: block.sourceModule,
sourceId: block.sourceId,
icon: block.icon,
isLive: block.isLive,
projectId: block.projectId,
};
}

View file

@ -9,6 +9,7 @@
import { Trash, MapPin, Clock, X } from '@manacore/shared-icons';
import type { ViewProps } from '$lib/app-registry';
import type { LocalEvent } from '../types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { useAllTags, getTagsByIds } from '$lib/stores/tags.svelte';
import LinkedItems from '$lib/components/links/LinkedItems.svelte';
import { toastStore } from '@manacore/shared-ui/toast';
@ -17,6 +18,7 @@
let eventId = $derived(params.eventId as string);
let event = $state<LocalEvent | null>(null);
let timeBlock = $state<LocalTimeBlock | null>(null);
let confirmDelete = $state(false);
let editTitle = $state('');
@ -49,18 +51,28 @@
});
$effect(() => {
const sub = liveQuery(() => db.table<LocalEvent>('events').get(eventId)).subscribe((val) => {
event = val ?? null;
if (val && !focused) {
editTitle = val.title;
editDate = val.startDate.split('T')[0];
editStartTime = val.startDate.includes('T')
? val.startDate.split('T')[1]?.substring(0, 5)
const sub = liveQuery(async () => {
const ev = await db.table<LocalEvent>('events').get(eventId);
if (!ev) return { event: null, block: null };
const block = ev.timeBlockId
? await db.table<LocalTimeBlock>('timeBlocks').get(ev.timeBlockId)
: null;
return { event: ev, block: block ?? null };
}).subscribe((val) => {
event = val?.event ?? null;
timeBlock = val?.block ?? null;
if (val?.event && val?.block && !focused) {
const tb = val.block;
editTitle = val.event.title;
editDate = tb.startDate.split('T')[0];
editStartTime = tb.startDate.includes('T')
? tb.startDate.split('T')[1]?.substring(0, 5)
: '';
editEndTime = val.endDate.includes('T') ? val.endDate.split('T')[1]?.substring(0, 5) : '';
editLocation = val.location ?? '';
editDescription = val.description ?? '';
editAllDay = val.allDay;
const endStr = tb.endDate ?? tb.startDate;
editEndTime = endStr.includes('T') ? endStr.split('T')[1]?.substring(0, 5) : '';
editLocation = val.event.location ?? '';
editDescription = val.event.description ?? '';
editAllDay = tb.allDay;
}
});
return () => sub.unsubscribe();
@ -157,10 +169,10 @@
/>
</div>
{#if event.recurrenceRule}
{#if timeBlock?.recurrenceRule}
<div class="prop-row">
<span class="prop-icon">🔁</span>
<span class="prop-value recurrence">{event.recurrenceRule}</span>
<span class="prop-value recurrence">{timeBlock.recurrenceRule}</span>
</div>
{/if}
</div>

View file

@ -7,6 +7,7 @@
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalHabit, LocalHabitLog, Habit, HabitLog } from './types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
import { EMOJI_TO_ICON_MAP } from './types';
// ─── Type Converters ───────────────────────────────────────
@ -25,13 +26,16 @@ export function toHabit(local: LocalHabit): Habit {
};
}
export function toHabitLog(local: LocalHabitLog): HabitLog {
/** Convert LocalHabitLog + its TimeBlock into a domain HabitLog. */
export function toHabitLog(local: LocalHabitLog, block?: LocalTimeBlock | null): HabitLog {
const now = new Date().toISOString();
return {
id: local.id,
habitId: local.habitId,
timestamp: local.timestamp,
timeBlockId: local.timeBlockId,
timestamp: block?.startDate ?? local.createdAt ?? now,
note: local.note,
createdAt: local.createdAt ?? new Date().toISOString(),
createdAt: local.createdAt ?? now,
};
}
@ -47,7 +51,17 @@ export function useAllHabits() {
export function useAllHabitLogs() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalHabitLog>('habitLogs').toArray();
return locals.filter((l) => !l.deletedAt).map(toHabitLog);
const active = locals.filter((l) => !l.deletedAt);
// Batch-fetch all related timeBlocks
const blockIds = active.map((l) => l.timeBlockId).filter(Boolean);
const blocks =
blockIds.length > 0
? await db.table<LocalTimeBlock>('timeBlocks').where('id').anyOf(blockIds).toArray()
: [];
const blocksById = new Map(blocks.map((b) => [b.id, b]));
return active.map((l) => toHabitLog(l, blocksById.get(l.timeBlockId)));
}, [] as HabitLog[]);
}
@ -58,9 +72,17 @@ export function useHabitLogsForHabit(habitId: string) {
.where('habitId')
.equals(habitId)
.toArray();
return locals
.filter((l) => !l.deletedAt)
.map(toHabitLog)
const active = locals.filter((l) => !l.deletedAt);
const blockIds = active.map((l) => l.timeBlockId).filter(Boolean);
const blocks =
blockIds.length > 0
? await db.table<LocalTimeBlock>('timeBlocks').where('id').anyOf(blockIds).toArray()
: [];
const blocksById = new Map(blocks.map((b) => [b.id, b]));
return active
.map((l) => toHabitLog(l, blocksById.get(l.timeBlockId)))
.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
}, [] as HabitLog[]);
}

View file

@ -1,12 +1,13 @@
/**
* Habits Store Mutation-Only Service
*
* Creates a TimeBlock for each habit log (point-event or with duration).
* 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 { createBlock, deleteBlock } from '$lib/data/time-blocks/service';
import type { LocalHabit, LocalHabitLog } from '../types';
export const habitsStore = {
@ -15,6 +16,7 @@ export const habitsStore = {
icon: string;
color: string;
targetPerDay?: number | null;
defaultDuration?: number | null;
}) {
const existing = await habitTable.toArray();
const count = existing.filter((h) => !h.deletedAt).length;
@ -25,6 +27,7 @@ export const habitsStore = {
icon: data.icon,
color: data.color,
targetPerDay: data.targetPerDay ?? null,
defaultDuration: data.defaultDuration ?? null,
order: count,
isArchived: false,
};
@ -36,7 +39,10 @@ export const habitsStore = {
async updateHabit(
id: string,
data: Partial<
Pick<LocalHabit, 'title' | 'icon' | 'color' | 'targetPerDay' | 'isArchived' | 'order'>
Pick<
LocalHabit,
'title' | 'icon' | 'color' | 'targetPerDay' | 'defaultDuration' | 'isArchived' | 'order'
>
>
) {
await habitTable.update(id, {
@ -50,19 +56,45 @@ export const habitsStore = {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
// Also soft-delete all logs for this habit
// Also soft-delete all logs and their timeBlocks
const logs = await habitLogTable.where('habitId').equals(id).toArray();
const now = new Date().toISOString();
for (const log of logs) {
if (log.timeBlockId) {
await deleteBlock(log.timeBlockId);
}
await habitLogTable.update(log.id, { deletedAt: now });
}
},
async logHabit(habitId: string, note?: string) {
const habit = await habitTable.get(habitId);
const now = new Date();
const logId = crypto.randomUUID();
// Calculate endDate if habit has a default duration
const endDate = habit?.defaultDuration
? new Date(now.getTime() + habit.defaultDuration * 1000).toISOString()
: null;
// 1. Create TimeBlock (point-event or with duration)
const timeBlockId = await createBlock({
startDate: now.toISOString(),
endDate,
kind: 'logged',
type: 'habit',
sourceModule: 'habits',
sourceId: logId,
title: habit?.title ?? 'Habit',
color: habit?.color ?? null,
icon: habit?.icon ?? null,
});
// 2. Create HabitLog
const newLog: LocalHabitLog = {
id: crypto.randomUUID(),
id: logId,
habitId,
timestamp: new Date().toISOString(),
timeBlockId,
note: note ?? null,
};
@ -71,6 +103,10 @@ export const habitsStore = {
},
async deleteLog(logId: string) {
const log = await habitLogTable.get(logId);
if (log?.timeBlockId) {
await deleteBlock(log.timeBlockId);
}
await habitLogTable.update(logId, {
deletedAt: new Date().toISOString(),
});
@ -80,9 +116,13 @@ export const habitsStore = {
const logs = await habitLogTable.where('habitId').equals(habitId).toArray();
const active = logs
.filter((l) => !l.deletedAt)
.sort((a, b) => (b.timestamp ?? '').localeCompare(a.timestamp ?? ''));
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
if (active.length > 0) {
await habitLogTable.update(active[0].id, {
const log = active[0];
if (log.timeBlockId) {
await deleteBlock(log.timeBlockId);
}
await habitLogTable.update(log.id, {
deletedAt: new Date().toISOString(),
});
}

View file

@ -14,13 +14,14 @@ export interface LocalHabit extends BaseRecord {
icon: string;
color: string;
targetPerDay: number | null;
defaultDuration?: number | null; // seconds (e.g., 300 for a 5min cigarette)
order: number;
isArchived: boolean;
}
export interface LocalHabitLog extends BaseRecord {
habitId: string;
timestamp: string; // ISO string
timeBlockId: string;
note: string | null;
}
@ -41,7 +42,8 @@ export interface Habit {
export interface HabitLog {
id: string;
habitId: string;
timestamp: string;
timeBlockId: string;
timestamp: string; // derived from timeBlock.startDate
note: string | null;
createdAt: string;
}

View file

@ -108,72 +108,59 @@ export const TIMES_GUEST_SEED = {
timeEntries: [
{
id: 'times-entry-1',
timeBlockId: 'times-tb-1',
projectId: DEMO_PROJECT_ID,
clientId: DEMO_CLIENT_ID,
description: 'Homepage Layout erstellen',
date: todayStr(),
startTime: new Date(new Date().setHours(9, 0, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(11, 30, 0, 0)).toISOString(),
duration: 9000,
isBillable: true,
isRunning: false,
tags: ['design'],
visibility: 'private' as const,
source: { app: 'manual' as const },
},
{
id: 'times-entry-2',
timeBlockId: 'times-tb-2',
projectId: DEMO_INTERNAL_PROJECT_ID,
description: 'Sprint Planning',
date: todayStr(),
startTime: new Date(new Date().setHours(11, 30, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(12, 15, 0, 0)).toISOString(),
duration: 2700,
isBillable: false,
isRunning: false,
tags: ['meeting'],
visibility: 'private' as const,
source: { app: 'manual' as const },
},
{
id: 'times-entry-3',
timeBlockId: 'times-tb-3',
projectId: DEMO_PROJECT_ID,
clientId: DEMO_CLIENT_ID,
description: 'API Integration',
date: todayStr(),
startTime: new Date(new Date().setHours(13, 0, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(15, 0, 0, 0)).toISOString(),
duration: 7200,
isBillable: true,
isRunning: false,
tags: ['development'],
visibility: 'private' as const,
source: { app: 'timer' as const },
},
{
id: 'times-entry-4',
timeBlockId: 'times-tb-4',
projectId: 'demo-project-app',
clientId: 'demo-client-startup',
description: 'Login Screen implementieren',
date: yesterdayStr(),
startTime: new Date(new Date().setHours(9, 0, 0, 0)).toISOString(),
endTime: new Date(new Date().setHours(12, 0, 0, 0)).toISOString(),
duration: 10800,
isBillable: true,
isRunning: false,
tags: ['development'],
visibility: 'private' as const,
source: { app: 'timer' as const },
},
{
id: 'times-entry-5',
timeBlockId: 'times-tb-5',
projectId: DEMO_PROJECT_ID,
clientId: DEMO_CLIENT_ID,
description: 'Code Review & Testing',
date: yesterdayStr(),
duration: 5400,
isBillable: true,
isRunning: false,
tags: ['review'],
visibility: 'private' as const,
source: { app: 'manual' as const },

View file

@ -2,6 +2,7 @@
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { timeEntryTable } from '$lib/modules/times/collections';
import { createBlock } from '$lib/data/time-blocks/service';
import { X, CurrencyDollar } from '@manacore/shared-icons';
import { TagField } from '@manacore/shared-ui';
import type { Project, Client } from '$lib/modules/times/types';
@ -75,17 +76,31 @@
? allProjects.value.find((p) => p.id === resolved.projectId)
: null;
const entryId = crypto.randomUUID();
const entryDate = resolved.date ? new Date(resolved.date).toISOString().split('T')[0] : date;
const startDate = resolved.startTime || `${entryDate}T00:00:00`;
const endDate = resolved.endTime || null;
// Create TimeBlock first
const timeBlockId = await createBlock({
startDate,
endDate,
kind: 'logged',
type: 'timeEntry',
sourceModule: 'times',
sourceId: entryId,
title: resolved.description || 'Time Entry',
projectId: resolved.projectId || null,
});
await timeEntryTable.add({
id: crypto.randomUUID(),
id: entryId,
timeBlockId,
projectId: resolved.projectId || null,
clientId: project?.clientId ?? null,
description: resolved.description,
date: resolved.date ? new Date(resolved.date).toISOString().split('T')[0] : date,
startTime: resolved.startTime || null,
endTime: resolved.endTime || null,
duration: totalSeconds,
isBillable: resolved.isBillable ?? isBillable,
isRunning: false,
tags: resolved.tagIds,
billingRate: null,
visibility: 'private',
@ -123,18 +138,28 @@
if (totalSeconds <= 0) return;
const project = projectId ? allProjects.value.find((p) => p.id === projectId) : null;
const entryId = crypto.randomUUID();
// Create TimeBlock first
const timeBlockId = await createBlock({
startDate: `${date}T00:00:00`,
endDate: null,
kind: 'logged',
type: 'timeEntry',
sourceModule: 'times',
sourceId: entryId,
title: description || 'Time Entry',
projectId: projectId || null,
});
await timeEntryTable.add({
id: crypto.randomUUID(),
id: entryId,
timeBlockId,
projectId: projectId || null,
clientId: project?.clientId ?? null,
description,
date,
startTime: null,
endTime: null,
duration: totalSeconds,
isBillable,
isRunning: false,
tags: selectedTagIds,
billingRate: null,
visibility: 'private',

View file

@ -26,6 +26,7 @@ import type {
SortOption,
} from './types';
import type { Alarm, Timer, WorldClock } from './types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
// ─── Type Converters ───────────────────────────────────────
@ -65,25 +66,29 @@ export function toProject(local: LocalProject): Project {
};
}
export function toTimeEntry(local: LocalTimeEntry): TimeEntry {
/** Convert LocalTimeEntry + its TimeBlock into a domain TimeEntry. */
export function toTimeEntry(local: LocalTimeEntry, block?: LocalTimeBlock | null): TimeEntry {
const now = new Date().toISOString();
return {
id: local.id,
timeBlockId: local.timeBlockId,
projectId: local.projectId ?? undefined,
clientId: local.clientId ?? undefined,
description: local.description,
date: local.date,
startTime: local.startTime ?? undefined,
endTime: local.endTime ?? undefined,
// Time fields derived from TimeBlock
date: block?.startDate?.split('T')[0] ?? now.split('T')[0],
startTime: block?.startDate ?? undefined,
endTime: block?.endDate ?? undefined,
isRunning: block?.isLive ?? false,
duration: local.duration,
isBillable: local.isBillable,
isRunning: local.isRunning,
tags: local.tags,
billingRate: local.billingRate ?? undefined,
visibility: local.visibility,
guildId: local.guildId ?? undefined,
source: local.source ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
createdAt: local.createdAt ?? now,
updatedAt: local.updatedAt ?? now,
};
}
@ -184,7 +189,17 @@ export function useAllProjects() {
export function useAllTimeEntries() {
return liveQuery(async () => {
const locals = await db.table<LocalTimeEntry>('timeEntries').toArray();
return locals.filter((e) => !e.deletedAt).map(toTimeEntry);
const active = locals.filter((e) => !e.deletedAt);
// Batch-fetch all related timeBlocks
const blockIds = active.map((e) => e.timeBlockId).filter(Boolean);
const blocks =
blockIds.length > 0
? await db.table<LocalTimeBlock>('timeBlocks').where('id').anyOf(blockIds).toArray()
: [];
const blocksById = new Map(blocks.map((b) => [b.id, b]));
return active.map((e) => toTimeEntry(e, blocksById.get(e.timeBlockId)));
});
}

View file

@ -1,17 +1,21 @@
/**
* Timer Store manages the active time tracking timer.
*
* The timer state persists in IndexedDB via the timeEntries table.
* When a timer is running, there's a timeEntry with isRunning=true.
* This store provides reactive access to the running entry and elapsed time.
* Timer state persists as a TimeBlock (isLive=true) + LocalTimeEntry.
* When running, the TimeBlock has endDate=null and isLive=true.
* On stop, endDate is set, isLive=false, and duration is rounded.
*/
import { browser } from '$app/environment';
import { db } from '$lib/data/database';
import { timeEntryTable, settingsTable } from '$lib/modules/times/collections';
import { roundDuration } from '$lib/modules/times/utils/rounding';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import type { LocalTimeEntry } from '$lib/modules/times/types';
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
let runningEntry = $state<LocalTimeEntry | null>(null);
let runningBlock = $state<LocalTimeBlock | null>(null);
let elapsedSeconds = $state(0);
let tickInterval: ReturnType<typeof setInterval> | null = null;
let autoSaveInterval: ReturnType<typeof setInterval> | null = null;
@ -19,8 +23,8 @@ let autoSaveInterval: ReturnType<typeof setInterval> | null = null;
function startTicking() {
stopTicking();
tickInterval = setInterval(() => {
if (runningEntry?.startTime) {
elapsedSeconds = Math.floor((Date.now() - new Date(runningEntry.startTime).getTime()) / 1000);
if (runningBlock?.startDate) {
elapsedSeconds = Math.floor((Date.now() - new Date(runningBlock.startDate).getTime()) / 1000);
}
}, 1000);
}
@ -51,6 +55,9 @@ export const timerStore = {
get runningEntry() {
return runningEntry;
},
get runningBlock() {
return runningBlock;
},
get elapsedSeconds() {
return elapsedSeconds;
},
@ -58,18 +65,22 @@ export const timerStore = {
return runningEntry !== null;
},
/** Initialize: check for any running entry in IndexedDB */
/** Initialize: check for any live timeBlock of type timeEntry */
async initialize() {
if (!browser) return;
const entries = await timeEntryTable.toArray();
const running = entries.find((e) => e.isRunning && !e.deletedAt);
if (running) {
runningEntry = running;
if (running.startTime) {
elapsedSeconds = Math.floor((Date.now() - new Date(running.startTime).getTime()) / 1000);
const blocks = await db.table<LocalTimeBlock>('timeBlocks').toArray();
const liveBlock = blocks.find(
(b) => b.isLive && !b.deletedAt && b.type === 'timeEntry' && b.sourceModule === 'times'
);
if (liveBlock) {
runningBlock = liveBlock;
const entry = await timeEntryTable.get(liveBlock.sourceId);
if (entry && !entry.deletedAt) {
runningEntry = entry;
elapsedSeconds = Math.floor((Date.now() - new Date(liveBlock.startDate).getTime()) / 1000);
startTicking();
startAutoSave();
}
startTicking();
startAutoSave();
}
},
@ -87,17 +98,30 @@ export const timerStore = {
}
const now = new Date();
const entryId = crypto.randomUUID();
// 1. Create TimeBlock (owns time dimension)
const timeBlockId = await createBlock({
startDate: now.toISOString(),
endDate: null,
isLive: true,
kind: 'logged',
type: 'timeEntry',
sourceModule: 'times',
sourceId: entryId,
title: options?.description || 'Time Entry',
projectId: options?.projectId ?? null,
});
// 2. Create LocalTimeEntry (domain data)
const entry: LocalTimeEntry = {
id: crypto.randomUUID(),
id: entryId,
timeBlockId,
projectId: options?.projectId ?? null,
clientId: options?.clientId ?? null,
description: options?.description ?? '',
date: now.toISOString().split('T')[0],
startTime: now.toISOString(),
endTime: null,
duration: 0,
isBillable: options?.isBillable ?? false,
isRunning: true,
tags: options?.tags ?? [],
billingRate: null,
visibility: 'private',
@ -107,6 +131,7 @@ export const timerStore = {
await timeEntryTable.add(entry);
runningEntry = entry;
runningBlock = (await db.table<LocalTimeBlock>('timeBlocks').get(timeBlockId)) ?? null;
elapsedSeconds = 0;
startTicking();
startAutoSave();
@ -114,12 +139,12 @@ export const timerStore = {
/** Stop the running timer */
async stop(): Promise<LocalTimeEntry | null> {
if (!runningEntry) return null;
if (!runningEntry || !runningBlock) return null;
const now = new Date();
const finalDuration = runningEntry.startTime
? Math.floor((now.getTime() - new Date(runningEntry.startTime).getTime()) / 1000)
: elapsedSeconds;
const finalDuration = Math.floor(
(now.getTime() - new Date(runningBlock.startDate).getTime()) / 1000
);
// Apply rounding from settings
const settings = await settingsTable.toArray();
@ -128,30 +153,36 @@ export const timerStore = {
? roundDuration(finalDuration, s.roundingIncrement, s.roundingMethod)
: finalDuration;
// Update TimeBlock: set endDate, isLive=false
await updateBlock(runningBlock.id, {
endDate: now.toISOString(),
isLive: false,
});
// Update TimeEntry: set final duration
await timeEntryTable.update(runningEntry.id, {
isRunning: false,
endTime: now.toISOString(),
duration: roundedDuration,
});
const stoppedEntry = {
...runningEntry,
isRunning: false,
endTime: now.toISOString(),
duration: roundedDuration,
};
stopTicking();
runningEntry = null;
runningBlock = null;
elapsedSeconds = 0;
return stoppedEntry as LocalTimeEntry;
},
/** Discard the running timer without saving */
async discard() {
if (!runningEntry) return;
if (!runningEntry || !runningBlock) return;
await deleteBlock(runningBlock.id);
await timeEntryTable.delete(runningEntry.id);
stopTicking();
runningEntry = null;
runningBlock = null;
elapsedSeconds = 0;
},
@ -161,9 +192,17 @@ export const timerStore = {
Pick<LocalTimeEntry, 'projectId' | 'clientId' | 'description' | 'isBillable' | 'tags'>
>
) {
if (!runningEntry) return;
if (!runningEntry || !runningBlock) return;
await timeEntryTable.update(runningEntry.id, updates);
runningEntry = { ...runningEntry, ...updates };
// Keep TimeBlock title/projectId in sync
const blockUpdates: Record<string, unknown> = {};
if (updates.description !== undefined) blockUpdates.title = updates.description;
if (updates.projectId !== undefined) blockUpdates.projectId = updates.projectId;
if (Object.keys(blockUpdates).length > 0) {
await updateBlock(runningBlock.id, blockUpdates);
}
},
/** Cleanup on unmount */

View file

@ -90,15 +90,17 @@ export interface Project {
export interface TimeEntry {
id: string;
timeBlockId: string;
projectId?: string;
clientId?: string;
description: string;
date: string;
startTime?: string;
endTime?: string;
// Time fields from TimeBlock (denormalized for convenience in pure helpers)
date: string; // YYYY-MM-DD derived from timeBlock.startDate
startTime?: string; // from timeBlock.startDate
endTime?: string; // from timeBlock.endDate
isRunning: boolean; // from timeBlock.isLive
duration: number;
isBillable: boolean;
isRunning: boolean;
tags: string[];
billingRate?: BillingRate;
visibility: ProjectVisibility;
@ -179,15 +181,12 @@ export interface LocalProject extends BaseRecord {
}
export interface LocalTimeEntry extends BaseRecord {
timeBlockId: string;
projectId?: string | null;
clientId?: string | null;
description: string;
date: string;
startTime?: string | null;
endTime?: string | null;
duration: number;
duration: number; // billable/rounded seconds (authoritative for billing)
isBillable: boolean;
isRunning: boolean;
tags: string[];
billingRate?: BillingRate | null;
visibility: ProjectVisibility;

View file

@ -4,6 +4,7 @@
import type { Task, TaskPriority, Subtask } from '../types';
import { reminderTable } from '../collections';
import { createBlock, updateBlock, deleteBlock, getBlock } from '$lib/data/time-blocks/service';
export interface TaskFormState {
title: string;
@ -42,12 +43,23 @@ export function useTaskForm() {
let showDeleteConfirm = $state(false);
let isLoading = $state(false);
function initFromTask(task: Task) {
async function initFromTask(task: Task) {
title = task.title;
description = task.description ?? '';
dueDate = task.dueDate ? task.dueDate.split('T')[0] : '';
dueTime = task.scheduledStartTime ?? '';
startDate = task.scheduledDate ? task.scheduledDate.split('T')[0] : '';
// Load scheduled time from TimeBlock if scheduled
if (task.scheduledBlockId) {
const block = await getBlock(task.scheduledBlockId);
if (block) {
startDate = block.startDate.split('T')[0];
dueTime = block.startDate.includes('T')
? block.startDate.split('T')[1]?.substring(0, 5)
: '';
}
} else {
dueTime = '';
startDate = '';
}
priority = task.priority;
status = task.status;
subtasks = task.subtasks ? [...task.subtasks] : [];
@ -84,8 +96,10 @@ export function useTaskForm() {
description: description || undefined,
priority,
dueDate: dueDate ? new Date(dueDate).toISOString() : null,
scheduledDate: startDate ? new Date(startDate).toISOString() : null,
scheduledStartTime: dueTime || null,
// Schedule fields are handled via _scheduleStartDate and _scheduleStartTime
// for the task store to create/update/delete the TimeBlock
_scheduleStartDate: startDate || null,
_scheduleStartTime: dueTime || null,
estimatedDuration: effectiveDuration,
recurrenceRule: recurrenceRule || null,
subtasks: subtasks.length > 0 ? subtasks : null,

View file

@ -24,8 +24,7 @@ export function toTask(local: LocalTask): Task {
title: local.title,
description: local.description,
dueDate: local.dueDate,
scheduledDate: local.scheduledDate,
scheduledStartTime: local.scheduledStartTime,
scheduledBlockId: local.scheduledBlockId,
estimatedDuration: local.estimatedDuration,
priority: local.priority,
status: local.isCompleted ? 'completed' : 'pending',

View file

@ -1,13 +1,14 @@
/**
* Tasks Store Mutation-Only Service
*
* When a task is scheduled on the calendar, a TimeBlock is created.
* All reads are handled by liveQuery hooks in queries.ts.
* This store only provides write operations.
*/
import { taskTable } from '../collections';
import { toTask } from '../queries';
import type { LocalTask, TaskPriority, Subtask } from '../types';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import { TodoEvents } from '@manacore/shared-utils/analytics';
export const tasksStore = {
@ -20,24 +21,51 @@ export const tasksStore = {
subtasks?: Subtask[];
recurrenceRule?: string;
estimatedDuration?: number;
// Optional: schedule on calendar
scheduleStartDate?: string; // YYYY-MM-DD
scheduleStartTime?: string; // HH:mm
}) {
const existing = await taskTable.toArray();
const count = existing.filter((t) => !t.deletedAt).length;
const taskId = crypto.randomUUID();
let scheduledBlockId: string | null = null;
// Create TimeBlock if scheduling on calendar
if (data.scheduleStartDate) {
const startISO = data.scheduleStartTime
? `${data.scheduleStartDate}T${data.scheduleStartTime}:00`
: `${data.scheduleStartDate}T09:00:00`;
const durationMs = data.estimatedDuration ? data.estimatedDuration * 1000 : 3600000;
const endISO = new Date(new Date(startISO).getTime() + durationMs).toISOString();
scheduledBlockId = await createBlock({
startDate: startISO,
endDate: endISO,
allDay: !data.scheduleStartTime,
kind: 'scheduled',
type: 'task',
sourceModule: 'todo',
sourceId: taskId,
title: data.title,
projectId: data.projectId ?? null,
});
}
const newLocal: LocalTask = {
id: crypto.randomUUID(),
id: taskId,
title: data.title,
description: data.description,
priority: data.priority ?? 'medium',
isCompleted: false,
dueDate: data.dueDate ?? null,
scheduledBlockId,
estimatedDuration: data.estimatedDuration ?? null,
order: count,
recurrenceRule: data.recurrenceRule ?? null,
subtasks: data.subtasks,
};
// Set projectId if provided
if (data.projectId !== undefined) {
(newLocal as Record<string, unknown>).projectId = data.projectId;
}
@ -47,24 +75,64 @@ export const tasksStore = {
return toTask(newLocal);
},
async updateTask(
id: string,
data: Partial<
Pick<
LocalTask,
| 'title'
| 'description'
| 'dueDate'
| 'priority'
| 'isCompleted'
| 'order'
| 'subtasks'
| 'recurrenceRule'
| 'estimatedDuration'
| 'metadata'
>
>
) {
async updateTask(id: string, data: Record<string, unknown>) {
const task = await taskTable.get(id);
if (!task) return;
// Handle schedule changes via TimeBlock
const schedStartDate = data._scheduleStartDate as string | null | undefined;
const schedStartTime = data._scheduleStartTime as string | null | undefined;
delete data._scheduleStartDate;
delete data._scheduleStartTime;
if (schedStartDate !== undefined) {
if (schedStartDate) {
// Schedule or reschedule
const startISO = schedStartTime
? `${schedStartDate}T${schedStartTime}:00`
: `${schedStartDate}T09:00:00`;
const estDuration =
(data.estimatedDuration as number | undefined) ?? task.estimatedDuration;
const durationMs = estDuration ? estDuration * 1000 : 3600000;
const endISO = new Date(new Date(startISO).getTime() + durationMs).toISOString();
if (task.scheduledBlockId) {
// Update existing block
await updateBlock(task.scheduledBlockId, {
startDate: startISO,
endDate: endISO,
allDay: !schedStartTime,
title: (data.title as string) ?? task.title,
});
} else {
// Create new block
const blockId = await createBlock({
startDate: startISO,
endDate: endISO,
allDay: !schedStartTime,
kind: 'scheduled',
type: 'task',
sourceModule: 'todo',
sourceId: id,
title: (data.title as string) ?? task.title,
projectId: (data.projectId as string) ?? task.projectId ?? null,
});
data.scheduledBlockId = blockId;
}
} else {
// Unschedule: delete the TimeBlock
if (task.scheduledBlockId) {
await deleteBlock(task.scheduledBlockId);
data.scheduledBlockId = null;
}
}
}
// Keep TimeBlock title in sync if title changed
if (data.title !== undefined && task.scheduledBlockId && schedStartDate === undefined) {
await updateBlock(task.scheduledBlockId, { title: data.title as string });
}
await taskTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
@ -73,6 +141,11 @@ export const tasksStore = {
},
async deleteTask(id: string) {
const task = await taskTable.get(id);
if (task?.scheduledBlockId) {
await deleteBlock(task.scheduledBlockId);
}
await taskTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),

View file

@ -30,8 +30,7 @@ export interface LocalTask extends BaseRecord {
isCompleted: boolean;
completedAt?: string | null;
dueDate?: string | null;
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledBlockId?: string | null; // TimeBlock ID when task is scheduled on calendar
estimatedDuration?: number | null;
order: number;
recurrenceRule?: string | null;
@ -106,8 +105,7 @@ export interface Task {
title: string;
description?: string | null;
dueDate?: string | null;
scheduledDate?: string | null;
scheduledStartTime?: string | null;
scheduledBlockId?: string | null;
estimatedDuration?: number | null;
priority: TaskPriority;
status: TaskStatus;

View file

@ -78,17 +78,21 @@ export async function generateSuggestions(): Promise<AutomationSuggestion[]> {
// ─── Events ↔ Habits ────────────────────────────────────
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
const events = await db
.table('events')
const calendarBlocks = await db
.table('timeBlocks')
.toArray()
.then((all) =>
.then((all: Record<string, unknown>[]) =>
all.filter(
(e: Record<string, unknown>) => !e.deletedAt && (e.startDate as string) >= thirtyDaysAgo
(b) =>
!b.deletedAt &&
b.sourceModule === 'calendar' &&
b.type === 'event' &&
(b.startDate as string) >= thirtyDaysAgo
)
);
for (const habit of habits) {
const matchingEvents = events.filter((e: Record<string, unknown>) =>
const matchingEvents = calendarBlocks.filter((e: Record<string, unknown>) =>
titleContains(String(e.title ?? ''), habit.title)
);

View file

@ -1,18 +1,19 @@
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useAllCalendars, useAllEvents } from '$lib/modules/calendar/queries';
import { useAllCalendars, useAllCalendarItems } from '$lib/modules/calendar/queries';
import { calendarViewStore } from '$lib/modules/calendar/stores/view.svelte';
let { children }: { children: Snippet } = $props();
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allCalendars = useAllCalendars();
const allEvents = useAllEvents();
const allCalendarItems = useAllCalendarItems();
// Provide data to child components via Svelte context
// calendarEvents now contains ALL timeBlock types (events, tasks, habits, timeEntries)
setContext('calendars', allCalendars);
setContext('calendarEvents', allEvents);
setContext('calendarEvents', allCalendarItems);
// Initialize view preferences
calendarViewStore.initialize();