feat(timeblocks): cross-module drag & drop + activity feed widget

Cross-Module Drag & Drop:
- WeekView day columns accept 'task' and 'habit' drops
- Dropping a task auto-schedules it on that day (creates TimeBlock)
- Dropping a habit creates a logged block on that day
- HabitTile now has dragSource (long-press to drag)

Activity Feed:
- ActivityFeedWidget shows the 10 most recently updated timeBlocks
- Shows type icon, title, action label (running/completed/planned), time ago
- Registered as 'activity-feed' dashboard widget
- i18n keys added (de + en)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-05 17:55:12 +02:00
parent 105f99459e
commit a5f5c8b63f
7 changed files with 195 additions and 2 deletions

View file

@ -31,6 +31,7 @@ import ActiveTimerWidget from '$lib/modules/core/widgets/ActiveTimerWidget.svelt
import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgressWidget.svelte';
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';
export const widgetComponents: Record<WidgetType, Component> = {
credits: CreditsWidget,
@ -54,4 +55,5 @@ export const widgetComponents: Record<WidgetType, Component> = {
'nutrition-progress': NutritionProgressWidget,
'plant-watering': PlantWateringWidget,
'day-timeline': DayTimelineWidget,
'activity-feed': ActivityFeedWidget,
};

View file

@ -0,0 +1,120 @@
<script lang="ts">
/**
* ActivityFeedWidget — Recent timeBlock activity across all modules.
*
* Shows a chronological feed of recently created/updated timeBlocks.
* Reads directly from IndexedDB, auto-updates via liveQuery.
*/
import { _ } from 'svelte-i18n';
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { db } from '$lib/data/database';
import type { LocalTimeBlock, TimeBlockType } from '$lib/data/time-blocks/types';
import { toTimeBlock } from '$lib/data/time-blocks/queries';
import type { TimeBlock } from '$lib/data/time-blocks/types';
import {
CalendarBlank,
CheckSquare,
Timer,
Heart,
Lightning,
Clock,
Activity,
} from '@manacore/shared-icons';
import { getIconComponent } from '@manacore/shared-icons';
import { formatDistanceToNow } from 'date-fns';
import { de } from 'date-fns/locale';
const MAX_ITEMS = 10;
const recentQuery = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalTimeBlock>('timeBlocks').toArray();
return locals
.filter((b) => !b.deletedAt)
.map(toTimeBlock)
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
.slice(0, MAX_ITEMS);
}, [] as TimeBlock[]);
let items = $derived(recentQuery.value ?? []);
const typeIcons: Record<string, typeof CalendarBlank> = {
event: CalendarBlank,
task: CheckSquare,
timeEntry: Timer,
habit: Heart,
focus: Lightning,
break: Clock,
};
function timeAgo(iso: string): string {
return formatDistanceToNow(new Date(iso), { addSuffix: true, locale: de });
}
function actionLabel(block: TimeBlock): string {
if (block.isLive) return 'Läuft';
if (block.linkedBlockId) return 'Erledigt';
if (block.kind === 'scheduled') return 'Geplant';
return 'Erfasst';
}
</script>
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
<Activity size={20} />
{$_('dashboard.widgets.activity_feed.title', { default: 'Aktivität' })}
</h3>
</div>
{#if recentQuery.loading}
<div class="space-y-2">
{#each Array(4) as _}
<div class="h-8 animate-pulse rounded bg-surface-hover"></div>
{/each}
</div>
{:else if items.length === 0}
<div class="py-6 text-center">
<Activity size={32} class="mx-auto mb-2 text-muted-foreground" />
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.activity_feed.empty', { default: 'Noch keine Aktivität' })}
</p>
</div>
{:else}
<div class="space-y-1">
{#each items as block (block.id)}
{@const TypeIcon = typeIcons[block.type] ?? CalendarBlank}
{@const habitIcon =
block.type === 'habit' && block.icon ? getIconComponent(block.icon) : null}
<div
class="flex items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors hover:bg-surface-hover"
>
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-content-center rounded-full"
class:animate-pulse={block.isLive}
style="background: {block.color || '#6b7280'}20; color: {block.color || '#6b7280'}"
>
{#if habitIcon}
<svelte:component this={habitIcon} size={12} />
{:else}
<svelte:component this={TypeIcon} size={12} />
{/if}
</div>
<div class="min-w-0 flex-1">
<span class="truncate text-sm">{block.title}</span>
</div>
<div class="flex flex-shrink-0 flex-col items-end">
<span class="text-[0.625rem] font-medium text-muted-foreground">
{actionLabel(block)}
</span>
<span class="text-[0.5625rem] text-muted-foreground">
{timeAgo(block.updatedAt)}
</span>
</div>
</div>
{/each}
</div>
{/if}
</div>

View file

@ -139,6 +139,11 @@
"title": "Mein Tag",
"description": "Chronologische Tagesansicht aller Aktivitäten",
"empty": "Noch nichts heute"
},
"activity_feed": {
"title": "Aktivität",
"description": "Letzte Änderungen über alle Module",
"empty": "Noch keine Aktivität"
}
}
}

View file

@ -139,6 +139,11 @@
"title": "My Day",
"description": "Chronological timeline of all activities",
"empty": "Nothing yet today"
},
"activity_feed": {
"title": "Activity",
"description": "Recent changes across all modules",
"empty": "No activity yet"
}
}
}

View file

@ -2,6 +2,9 @@
import { onMount, getContext } from 'svelte';
import { calendarViewStore } from '../stores/view.svelte';
import { eventsStore } from '../stores/events.svelte';
import { createBlock } from '$lib/data/time-blocks/service';
import { dropTarget } from '@manacore/shared-ui/dnd';
import type { DragPayload } from '@manacore/shared-ui/dnd';
import {
getEventsForDay,
getEventsInRange,
@ -172,6 +175,38 @@
onEventClick?.(event);
}
/** Handle cross-module drop (task/habit onto calendar). */
async function handleCrossModuleDrop(day: Date, payload: DragPayload) {
const data = payload.data as Record<string, unknown>;
const defaultStart = new Date(day);
defaultStart.setHours(9, 0, 0, 0);
const defaultEnd = new Date(day);
defaultEnd.setHours(10, 0, 0, 0);
if (payload.type === 'task') {
// Schedule task on calendar
const { tasksStore } = await import('$lib/modules/todo/stores/tasks.svelte');
const dateStr = format(day, 'yyyy-MM-dd');
await tasksStore.updateTask(data.id as string, {
_scheduleStartDate: dateStr,
_scheduleStartTime: '09:00',
});
} else if (payload.type === 'habit') {
// Create a logged habit block at this day
await createBlock({
startDate: defaultStart.toISOString(),
endDate: defaultEnd.toISOString(),
kind: 'logged',
type: 'habit',
sourceModule: 'habits',
sourceId: (data.id as string) || crypto.randomUUID(),
title: (data.title as string) || 'Habit',
color: (data.color as string) || null,
icon: (data.icon as string) || null,
});
}
}
function formatHour(hour: number): string {
return `${hour.toString().padStart(2, '0')}:00`;
}
@ -245,6 +280,10 @@
dragToCreate.createTargetDay &&
isSameDay(day, dragToCreate.createTargetDay)}
onpointerdown={dragToCreate.startCreate}
use:dropTarget={{
accepts: ['task', 'habit'],
onDrop: (p) => handleCrossModuleDrop(day, p),
}}
>
{#each hours as hour}
<div class="hour-slot" role="button" tabindex="-1"></div>

View file

@ -8,6 +8,7 @@
import { habitsStore } from '../stores/habits.svelte';
import { DynamicIcon } from '@manacore/shared-ui/atoms';
import { CaretRight } from '@manacore/shared-icons';
import { dragSource } from '@manacore/shared-ui/dnd';
let {
habit,
@ -62,7 +63,19 @@
);
</script>
<div class="habit-tile-wrapper">
<div
class="habit-tile-wrapper"
use:dragSource={{
type: 'habit',
data: () => ({
id: habit.id,
title: habit.title,
color: habit.color,
icon: habit.icon,
}),
longPressMs: 600,
}}
>
<button
class="habit-tile"
class:pressing

View file

@ -28,7 +28,8 @@ export type WidgetType =
| 'active-timer' // Times: running timer
| 'nutrition-progress' // NutriPhi: today's calorie progress
| 'plant-watering' // Planta: plants due for watering
| 'day-timeline'; // TimeBlocks: chronological day timeline
| 'day-timeline' // TimeBlocks: chronological day timeline
| 'activity-feed'; // TimeBlocks: recent activity across modules
/**
* Widget size - maps to CSS Grid columns
@ -322,6 +323,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
defaultSize: 'medium',
allowMultiple: false,
},
{
type: 'activity-feed',
nameKey: 'dashboard.widgets.activity_feed.title',
descriptionKey: 'dashboard.widgets.activity_feed.description',
icon: '📊',
defaultSize: 'medium',
allowMultiple: false,
},
];
/**