mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
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:
parent
5fd9c1d11e
commit
0aa0d7b135
55 changed files with 1334 additions and 331 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
};
|
||||
4
apps/manacore/apps/web/src/lib/data/time-blocks/index.ts
Normal file
4
apps/manacore/apps/web/src/lib/data/time-blocks/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './types';
|
||||
export * from './collections';
|
||||
export * from './service';
|
||||
export * from './queries';
|
||||
206
apps/manacore/apps/web/src/lib/data/time-blocks/queries.ts
Normal file
206
apps/manacore/apps/web/src/lib/data/time-blocks/queries.ts
Normal 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;
|
||||
}
|
||||
75
apps/manacore/apps/web/src/lib/data/time-blocks/service.ts
Normal file
75
apps/manacore/apps/web/src/lib/data/time-blocks/service.ts
Normal 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;
|
||||
}
|
||||
110
apps/manacore/apps/web/src/lib/data/time-blocks/types.ts
Normal file
110
apps/manacore/apps/web/src/lib/data/time-blocks/types.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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[],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 } }));
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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[]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue