mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:41:09 +02:00
feat(timeblocks): drag external items, conflict detection, plan vs reality, timeline view
- Drag & drop: external timeBlocks (tasks, habits, timeEntries) can now be dragged and resized directly in calendar views via updateBlock() - Conflict detection: ConflictWarning component shows overlapping timeBlocks in EventForm and QuickEventPopover in real-time - Plan vs Reality: startFromScheduled() creates linked logged blocks from scheduled blocks, EventCard shows checkmark badge for linked blocks, linkBlocks() now validates kind compatibility - Timeline view: full-page /timeline route with chronological day view, day navigation, type filters, duration stats, live indicators, and connected dot+line visualization Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ec96b1bc83
commit
a1c3e99c7c
8 changed files with 689 additions and 1 deletions
|
|
@ -62,11 +62,50 @@ export async function deleteBlock(id: string): Promise<void> {
|
|||
export async function linkBlocks(scheduledId: string, loggedId: string): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await db.transaction('rw', timeBlockTable, async () => {
|
||||
const scheduled = await timeBlockTable.get(scheduledId);
|
||||
const logged = await timeBlockTable.get(loggedId);
|
||||
if (!scheduled || !logged) throw new Error('Block not found');
|
||||
if (scheduled.kind !== 'scheduled') throw new Error('First block must be scheduled');
|
||||
if (logged.kind !== 'logged') throw new Error('Second block must be logged');
|
||||
|
||||
await timeBlockTable.update(scheduledId, { linkedBlockId: loggedId, updatedAt: now });
|
||||
await timeBlockTable.update(loggedId, { linkedBlockId: scheduledId, updatedAt: now });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a logged block from a scheduled block (plan → reality).
|
||||
* Creates a new "logged" block linked to the scheduled one and returns its ID.
|
||||
*/
|
||||
export async function startFromScheduled(
|
||||
scheduledId: string,
|
||||
overrides?: { title?: string; color?: string; icon?: string; projectId?: string | null }
|
||||
): Promise<string> {
|
||||
const scheduled = await timeBlockTable.get(scheduledId);
|
||||
if (!scheduled || scheduled.deletedAt) throw new Error('Scheduled block not found');
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const loggedId = await createBlock({
|
||||
startDate: now,
|
||||
endDate: null,
|
||||
isLive: true,
|
||||
kind: 'logged',
|
||||
type: scheduled.type,
|
||||
sourceModule: scheduled.sourceModule,
|
||||
sourceId: scheduled.sourceId,
|
||||
linkedBlockId: scheduledId,
|
||||
title: overrides?.title ?? scheduled.title,
|
||||
color: overrides?.color ?? scheduled.color ?? null,
|
||||
icon: overrides?.icon ?? scheduled.icon ?? null,
|
||||
projectId: overrides?.projectId ?? scheduled.projectId ?? null,
|
||||
});
|
||||
|
||||
// Link back from scheduled → logged
|
||||
await timeBlockTable.update(scheduledId, { linkedBlockId: loggedId, updatedAt: now });
|
||||
|
||||
return loggedId;
|
||||
}
|
||||
|
||||
/** Get a single timeBlock by ID. */
|
||||
export async function getBlock(id: string): Promise<LocalTimeBlock | undefined> {
|
||||
const block = await timeBlockTable.get(id);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
<!--
|
||||
ConflictWarning — shows overlapping timeBlocks for a given time range.
|
||||
Used inline in EventForm, QuickEventPopover, and TaskSchedule.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||
import { toTimeBlock, findOverlaps } from '$lib/data/time-blocks/queries';
|
||||
import type { TimeBlock } from '$lib/data/time-blocks/types';
|
||||
import { Warning } from '@manacore/shared-icons';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
let {
|
||||
startDate,
|
||||
endDate,
|
||||
excludeBlockId,
|
||||
}: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
excludeBlockId?: string;
|
||||
} = $props();
|
||||
|
||||
let conflicts = $state<TimeBlock[]>([]);
|
||||
|
||||
$effect(() => {
|
||||
if (!startDate || !endDate) {
|
||||
conflicts = [];
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for overlaps
|
||||
const dayStart = startDate.split('T')[0] + 'T00:00:00';
|
||||
const dayEnd = startDate.split('T')[0] + 'T23:59:59';
|
||||
|
||||
db.table<LocalTimeBlock>('timeBlocks')
|
||||
.where('startDate')
|
||||
.between(dayStart, dayEnd, true, true)
|
||||
.toArray()
|
||||
.then((locals) => {
|
||||
const blocks = locals.filter((b) => !b.deletedAt).map(toTimeBlock);
|
||||
conflicts = findOverlaps(blocks, startDate, endDate, excludeBlockId);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if conflicts.length > 0}
|
||||
<div class="conflict-warning">
|
||||
<Warning size={14} class="warning-icon" />
|
||||
<div class="conflict-content">
|
||||
<span class="conflict-label">
|
||||
{conflicts.length === 1 ? 'Zeitkonflikt' : `${conflicts.length} Konflikte`}
|
||||
</span>
|
||||
{#each conflicts.slice(0, 2) as conflict}
|
||||
<span class="conflict-item">
|
||||
{conflict.title}
|
||||
({format(new Date(conflict.startDate), 'HH:mm')}–{conflict.endDate
|
||||
? format(new Date(conflict.endDate), 'HH:mm')
|
||||
: '...'})
|
||||
</span>
|
||||
{/each}
|
||||
{#if conflicts.length > 2}
|
||||
<span class="conflict-more">+{conflicts.length - 2} weitere</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.conflict-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-warning, 38 92% 50%) / 0.1);
|
||||
border: 1px solid hsl(var(--color-warning, 38 92% 50%) / 0.3);
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-warning, 38 92% 50%));
|
||||
}
|
||||
|
||||
.conflict-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.conflict-label {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conflict-item {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.conflict-more {
|
||||
opacity: 0.65;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { CalendarEvent } from '../types';
|
||||
import { eventsStore } from '../stores/events.svelte';
|
||||
import { CheckSquare, Clock, Timer, Lightning } from '@manacore/shared-icons';
|
||||
import { CheckSquare, Clock, Timer, Lightning, CheckCircle } from '@manacore/shared-icons';
|
||||
import { getIconComponent } from '@manacore/shared-icons';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -121,6 +121,9 @@
|
|||
{/if}
|
||||
|
||||
<span class="event-time">{formattedTime}</span>
|
||||
{#if event.linkedBlockId}
|
||||
<span class="linked-badge" title="Durchgeführt"><CheckCircle size={10} weight="fill" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="event-title">{event.title || (isDraft ? 'Neuer Termin' : '')}</span>
|
||||
{#if event.location}
|
||||
|
|
@ -259,6 +262,15 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.linked-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
opacity: 0.9;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
display: block;
|
||||
font-size: 0.6rem;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
import { format, addMinutes } from 'date-fns';
|
||||
import { TagField } from '@manacore/shared-ui';
|
||||
import { useAllTags } from '@manacore/shared-stores';
|
||||
import ConflictWarning from './ConflictWarning.svelte';
|
||||
|
||||
interface Props {
|
||||
mode: 'create' | 'edit';
|
||||
|
|
@ -174,6 +175,12 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !isAllDay && startDate && startTime && endDate && endTime}
|
||||
{@const fullStart = `${startDate}T${startTime}:00`}
|
||||
{@const fullEnd = `${endDate}T${endTime}:00`}
|
||||
<ConflictWarning startDate={fullStart} endDate={fullEnd} excludeBlockId={event?.timeBlockId} />
|
||||
{/if}
|
||||
|
||||
<div class="field">
|
||||
<label for="recurrence" class="label">Wiederholung</label>
|
||||
<select id="recurrence" class="input" bind:value={recurrenceRule}>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
ArrowsClockwise,
|
||||
TextAlignLeft,
|
||||
} from '@manacore/shared-icons';
|
||||
import ConflictWarning from './ConflictWarning.svelte';
|
||||
|
||||
interface Props {
|
||||
startTime: Date;
|
||||
|
|
@ -202,6 +203,13 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{#if !isAllDay && startDateStr && startTimeStr && endTimeStr}
|
||||
<ConflictWarning
|
||||
startDate="{startDateStr}T{startTimeStr}:00"
|
||||
endDate="{endDateStr}T{endTimeStr}:00"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Recurrence -->
|
||||
<div class="form-row">
|
||||
<ArrowsClockwise size={16} class="row-icon-el" />
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import type { CalendarEvent } from '../types';
|
|||
import { differenceInMinutes, addMinutes, setHours, setMinutes } from 'date-fns';
|
||||
import { toDate } from '../utils/event-date-helpers';
|
||||
import { eventsStore } from '../stores/events.svelte';
|
||||
import { updateBlock } from '$lib/data/time-blocks/service';
|
||||
import { formatTime, getDayFromX, getMinutesFromY } from '../utils/drag-helpers';
|
||||
|
||||
export interface EventDragDropConfig {
|
||||
|
|
@ -132,7 +133,14 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
|
|||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else if (draggedEvent.calendarId === '__external__') {
|
||||
// External items (tasks, habits, timeEntries): update TimeBlock directly
|
||||
await updateBlock(draggedEvent.timeBlockId, {
|
||||
startDate: newStart.toISOString(),
|
||||
endDate: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
// Native calendar events: update via eventsStore (updates both block + event)
|
||||
await eventsStore.updateEvent(draggedEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
|
|
@ -255,6 +263,11 @@ export function useEventDragDrop(getConfig: () => EventDragDropConfig) {
|
|||
startTime: newStart.toISOString(),
|
||||
endTime: newEnd.toISOString(),
|
||||
});
|
||||
} else if (resizeEvent.calendarId === '__external__') {
|
||||
await updateBlock(resizeEvent.timeBlockId, {
|
||||
startDate: newStart.toISOString(),
|
||||
endDate: newEnd.toISOString(),
|
||||
});
|
||||
} else {
|
||||
await eventsStore.updateEvent(resizeEvent.id, {
|
||||
startTime: newStart.toISOString(),
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export interface CalendarEvent {
|
|||
icon: string | null;
|
||||
isLive: boolean;
|
||||
projectId: string | null;
|
||||
linkedBlockId: string | null;
|
||||
}
|
||||
|
||||
export interface Calendar {
|
||||
|
|
@ -102,5 +103,6 @@ export function timeBlockToCalendarEvent(
|
|||
icon: block.icon,
|
||||
isLive: block.isLive,
|
||||
projectId: block.projectId,
|
||||
linkedBlockId: block.linkedBlockId,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
508
apps/manacore/apps/web/src/routes/(app)/timeline/+page.svelte
Normal file
508
apps/manacore/apps/web/src/routes/(app)/timeline/+page.svelte
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
<!--
|
||||
Timeline — Chronological day view of all timeBlocks.
|
||||
"What did I do today?" as a standalone page.
|
||||
-->
|
||||
<script lang="ts">
|
||||
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, getBlockDuration } from '$lib/data/time-blocks/queries';
|
||||
import type { TimeBlock } from '$lib/data/time-blocks/types';
|
||||
import { getIconComponent } from '@manacore/shared-icons';
|
||||
import {
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
CalendarBlank,
|
||||
CheckSquare,
|
||||
Timer,
|
||||
Heart,
|
||||
Lightning,
|
||||
Clock,
|
||||
Funnel,
|
||||
} from '@manacore/shared-icons';
|
||||
import { format, addDays, subDays, isToday, isTomorrow, isYesterday } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
let currentDate = $state(new Date());
|
||||
let showFilters = $state(false);
|
||||
let visibleTypes = $state<Set<TimeBlockType>>(
|
||||
new Set(['event', 'task', 'habit', 'timeEntry', 'focus', 'break'])
|
||||
);
|
||||
|
||||
let dateStr = $derived(format(currentDate, 'yyyy-MM-dd'));
|
||||
let dayStart = $derived(`${dateStr}T00:00:00.000Z`);
|
||||
let dayEnd = $derived(`${dateStr}T23:59:59.999Z`);
|
||||
|
||||
const blocksQuery = useLiveQueryWithDefault(async () => {
|
||||
const locals = await db
|
||||
.table<LocalTimeBlock>('timeBlocks')
|
||||
.where('startDate')
|
||||
.between(dayStart, dayEnd, true, true)
|
||||
.toArray();
|
||||
return locals
|
||||
.filter((b) => !b.deletedAt)
|
||||
.map(toTimeBlock)
|
||||
.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
}, [] as TimeBlock[]);
|
||||
|
||||
let allBlocks = $derived(blocksQuery.value ?? []);
|
||||
let blocks = $derived(allBlocks.filter((b) => visibleTypes.has(b.type)));
|
||||
|
||||
// Stats
|
||||
let totalSeconds = $derived(blocks.reduce((sum, b) => sum + getBlockDuration(b), 0));
|
||||
let liveBlock = $derived(blocks.find((b) => b.isLive));
|
||||
|
||||
const typeConfig: {
|
||||
type: TimeBlockType;
|
||||
icon: typeof CalendarBlank;
|
||||
label: string;
|
||||
color: string;
|
||||
}[] = [
|
||||
{ type: 'event', icon: CalendarBlank, label: 'Termine', color: '#3b82f6' },
|
||||
{ type: 'task', icon: CheckSquare, label: 'Aufgaben', color: '#f59e0b' },
|
||||
{ type: 'timeEntry', icon: Timer, label: 'Zeiten', color: '#8b5cf6' },
|
||||
{ type: 'habit', icon: Heart, label: 'Habits', color: '#22c55e' },
|
||||
{ type: 'focus', icon: Lightning, label: 'Fokus', color: '#ef4444' },
|
||||
];
|
||||
|
||||
function toggleType(type: TimeBlockType) {
|
||||
const next = new Set(visibleTypes);
|
||||
if (next.has(type)) next.delete(type);
|
||||
else next.add(type);
|
||||
visibleTypes = next;
|
||||
}
|
||||
|
||||
function formatHeaderDate(date: Date): string {
|
||||
if (isToday(date)) return 'Heute';
|
||||
if (isTomorrow(date)) return 'Morgen';
|
||||
if (isYesterday(date)) return 'Gestern';
|
||||
return format(date, 'EEEE, d. MMMM yyyy', { locale: de });
|
||||
}
|
||||
|
||||
function formatBlockTime(block: TimeBlock): string {
|
||||
const start = format(new Date(block.startDate), 'HH:mm');
|
||||
if (block.isLive) return `${start} — jetzt`;
|
||||
if (!block.endDate) return start;
|
||||
return `${start} — ${format(new Date(block.endDate), 'HH:mm')}`;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds === 0) return '';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h === 0) return `${m}m`;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function getTypeColor(type: TimeBlockType): string {
|
||||
return typeConfig.find((c) => c.type === type)?.color ?? '#6b7280';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="timeline-page">
|
||||
<!-- Header -->
|
||||
<header class="timeline-header">
|
||||
<div class="header-left">
|
||||
<h1 class="header-title">{formatHeaderDate(currentDate)}</h1>
|
||||
<div class="nav-buttons">
|
||||
<button onclick={() => (currentDate = subDays(currentDate, 1))} class="nav-btn">
|
||||
<CaretLeft size={18} />
|
||||
</button>
|
||||
<button onclick={() => (currentDate = new Date())} class="today-btn">Heute</button>
|
||||
<button onclick={() => (currentDate = addDays(currentDate, 1))} class="nav-btn">
|
||||
<CaretRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
{#if totalSeconds > 0}
|
||||
<span class="total-duration">{formatDuration(totalSeconds)} erfasst</span>
|
||||
{/if}
|
||||
<button
|
||||
class="filter-btn"
|
||||
class:active={visibleTypes.size < 6}
|
||||
onclick={() => (showFilters = !showFilters)}
|
||||
>
|
||||
<Funnel size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if showFilters}
|
||||
<div class="filter-bar">
|
||||
{#each typeConfig as cfg}
|
||||
{@const active = visibleTypes.has(cfg.type)}
|
||||
<button class="filter-chip" class:active onclick={() => toggleType(cfg.type)}>
|
||||
<svelte:component this={cfg.icon} size={14} />
|
||||
{cfg.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Timeline content -->
|
||||
<div class="timeline-content">
|
||||
{#if blocks.length === 0}
|
||||
<div class="empty">
|
||||
<Clock size={48} class="empty-icon" />
|
||||
<p>{isToday(currentDate) ? 'Noch nichts heute' : 'Keine Einträge an diesem Tag'}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="timeline-list">
|
||||
{#each blocks as block, i (block.id)}
|
||||
{@const duration = getBlockDuration(block)}
|
||||
{@const habitIcon =
|
||||
block.type === 'habit' && block.icon ? getIconComponent(block.icon) : null}
|
||||
{@const typeCfg = typeConfig.find((c) => c.type === block.type)}
|
||||
|
||||
<div class="timeline-item" class:live={block.isLive}>
|
||||
<!-- Time column -->
|
||||
<div class="time-col">
|
||||
<span class="time-label">{format(new Date(block.startDate), 'HH:mm')}</span>
|
||||
</div>
|
||||
|
||||
<!-- Dot + line -->
|
||||
<div class="dot-col">
|
||||
<div
|
||||
class="dot"
|
||||
class:live={block.isLive}
|
||||
style="background: {block.color || getTypeColor(block.type)}"
|
||||
></div>
|
||||
{#if i < blocks.length - 1}
|
||||
<div class="connector-line"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content-col">
|
||||
<div class="item-header">
|
||||
{#if habitIcon}
|
||||
<svelte:component
|
||||
this={habitIcon}
|
||||
size={16}
|
||||
style="color: {block.color || '#6b7280'}"
|
||||
/>
|
||||
{:else if typeCfg}
|
||||
<svelte:component this={typeCfg.icon} size={16} class="item-type-icon" />
|
||||
{/if}
|
||||
<span class="item-title">{block.title}</span>
|
||||
{#if block.linkedBlockId}
|
||||
<span class="linked-badge">erledigt</span>
|
||||
{/if}
|
||||
{#if block.isLive}
|
||||
<span class="live-badge">live</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="item-meta">
|
||||
<span>{formatBlockTime(block)}</span>
|
||||
{#if duration > 0}
|
||||
<span class="duration-pill">{formatDuration(duration)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if block.description}
|
||||
<p class="item-description">{block.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timeline-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: hsl(var(--color-background));
|
||||
}
|
||||
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
padding: 0.375rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.nav-btn:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.today-btn:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.total-duration {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.375rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: transparent;
|
||||
border-radius: var(--radius-md, 8px);
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.filter-btn:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
.filter-btn.active {
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-chip:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
.filter-chip.active {
|
||||
background: hsl(var(--color-primary) / 0.1);
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
|
||||
/* Timeline content */
|
||||
.timeline-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding: 4rem 0;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.empty p {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
min-height: 3.5rem;
|
||||
}
|
||||
|
||||
.timeline-item.live {
|
||||
background: hsl(var(--color-primary) / 0.03);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
}
|
||||
|
||||
/* Time column */
|
||||
.time-col {
|
||||
width: 3.5rem;
|
||||
flex-shrink: 0;
|
||||
padding-top: 0.75rem;
|
||||
text-align: right;
|
||||
padding-right: 0.75rem;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Dot + connector */
|
||||
.dot-col {
|
||||
width: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-top: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.dot.live {
|
||||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 currentColor;
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 4px currentColor;
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.connector-line {
|
||||
width: 2px;
|
||||
flex: 1;
|
||||
background: hsl(var(--color-border));
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.content-col {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0 1rem 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.linked-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-success, 142 71% 45%) / 0.15);
|
||||
color: hsl(var(--color-success, 142 71% 45%));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.live-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-primary) / 0.15);
|
||||
color: hsl(var(--color-primary));
|
||||
animation: pulse-badge 2s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse-badge {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.duration-pill {
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 9999px;
|
||||
background: hsl(var(--color-muted));
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.item-description {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
Loading…
Add table
Add a link
Reference in a new issue