mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-26 00:24:38 +02:00
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:
parent
105f99459e
commit
a5f5c8b63f
7 changed files with 195 additions and 2 deletions
|
|
@ -31,6 +31,7 @@ import ActiveTimerWidget from '$lib/modules/core/widgets/ActiveTimerWidget.svelt
|
||||||
import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgressWidget.svelte';
|
import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgressWidget.svelte';
|
||||||
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
|
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
|
||||||
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
|
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
|
||||||
|
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';
|
||||||
|
|
||||||
export const widgetComponents: Record<WidgetType, Component> = {
|
export const widgetComponents: Record<WidgetType, Component> = {
|
||||||
credits: CreditsWidget,
|
credits: CreditsWidget,
|
||||||
|
|
@ -54,4 +55,5 @@ export const widgetComponents: Record<WidgetType, Component> = {
|
||||||
'nutrition-progress': NutritionProgressWidget,
|
'nutrition-progress': NutritionProgressWidget,
|
||||||
'plant-watering': PlantWateringWidget,
|
'plant-watering': PlantWateringWidget,
|
||||||
'day-timeline': DayTimelineWidget,
|
'day-timeline': DayTimelineWidget,
|
||||||
|
'activity-feed': ActivityFeedWidget,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -139,6 +139,11 @@
|
||||||
"title": "Mein Tag",
|
"title": "Mein Tag",
|
||||||
"description": "Chronologische Tagesansicht aller Aktivitäten",
|
"description": "Chronologische Tagesansicht aller Aktivitäten",
|
||||||
"empty": "Noch nichts heute"
|
"empty": "Noch nichts heute"
|
||||||
|
},
|
||||||
|
"activity_feed": {
|
||||||
|
"title": "Aktivität",
|
||||||
|
"description": "Letzte Änderungen über alle Module",
|
||||||
|
"empty": "Noch keine Aktivität"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,11 @@
|
||||||
"title": "My Day",
|
"title": "My Day",
|
||||||
"description": "Chronological timeline of all activities",
|
"description": "Chronological timeline of all activities",
|
||||||
"empty": "Nothing yet today"
|
"empty": "Nothing yet today"
|
||||||
|
},
|
||||||
|
"activity_feed": {
|
||||||
|
"title": "Activity",
|
||||||
|
"description": "Recent changes across all modules",
|
||||||
|
"empty": "No activity yet"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
import { onMount, getContext } from 'svelte';
|
import { onMount, getContext } from 'svelte';
|
||||||
import { calendarViewStore } from '../stores/view.svelte';
|
import { calendarViewStore } from '../stores/view.svelte';
|
||||||
import { eventsStore } from '../stores/events.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 {
|
import {
|
||||||
getEventsForDay,
|
getEventsForDay,
|
||||||
getEventsInRange,
|
getEventsInRange,
|
||||||
|
|
@ -172,6 +175,38 @@
|
||||||
onEventClick?.(event);
|
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 {
|
function formatHour(hour: number): string {
|
||||||
return `${hour.toString().padStart(2, '0')}:00`;
|
return `${hour.toString().padStart(2, '0')}:00`;
|
||||||
}
|
}
|
||||||
|
|
@ -245,6 +280,10 @@
|
||||||
dragToCreate.createTargetDay &&
|
dragToCreate.createTargetDay &&
|
||||||
isSameDay(day, dragToCreate.createTargetDay)}
|
isSameDay(day, dragToCreate.createTargetDay)}
|
||||||
onpointerdown={dragToCreate.startCreate}
|
onpointerdown={dragToCreate.startCreate}
|
||||||
|
use:dropTarget={{
|
||||||
|
accepts: ['task', 'habit'],
|
||||||
|
onDrop: (p) => handleCrossModuleDrop(day, p),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#each hours as hour}
|
{#each hours as hour}
|
||||||
<div class="hour-slot" role="button" tabindex="-1"></div>
|
<div class="hour-slot" role="button" tabindex="-1"></div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
import { habitsStore } from '../stores/habits.svelte';
|
import { habitsStore } from '../stores/habits.svelte';
|
||||||
import { DynamicIcon } from '@manacore/shared-ui/atoms';
|
import { DynamicIcon } from '@manacore/shared-ui/atoms';
|
||||||
import { CaretRight } from '@manacore/shared-icons';
|
import { CaretRight } from '@manacore/shared-icons';
|
||||||
|
import { dragSource } from '@manacore/shared-ui/dnd';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
habit,
|
habit,
|
||||||
|
|
@ -62,7 +63,19 @@
|
||||||
);
|
);
|
||||||
</script>
|
</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
|
<button
|
||||||
class="habit-tile"
|
class="habit-tile"
|
||||||
class:pressing
|
class:pressing
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,8 @@ export type WidgetType =
|
||||||
| 'active-timer' // Times: running timer
|
| 'active-timer' // Times: running timer
|
||||||
| 'nutrition-progress' // NutriPhi: today's calorie progress
|
| 'nutrition-progress' // NutriPhi: today's calorie progress
|
||||||
| 'plant-watering' // Planta: plants due for watering
|
| '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
|
* Widget size - maps to CSS Grid columns
|
||||||
|
|
@ -322,6 +323,14 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
|
||||||
defaultSize: 'medium',
|
defaultSize: 'medium',
|
||||||
allowMultiple: false,
|
allowMultiple: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'activity-feed',
|
||||||
|
nameKey: 'dashboard.widgets.activity_feed.title',
|
||||||
|
descriptionKey: 'dashboard.widgets.activity_feed.description',
|
||||||
|
icon: '📊',
|
||||||
|
defaultSize: 'medium',
|
||||||
|
allowMultiple: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue