- Custom tooltip directive (WCAG AAA) on every button in the app - Two-column timer layout with sticky hero and recent entries sidebar - Timer font selector with 16 monospace Google Fonts and live preview - UI font selector with 15+ Google Fonts - Close-to-tray and minimize-to-tray settings - New app icons (no-glow variants), platform icon set - Mini timer pop-out window - Favorites strip with drag-reorder and inline actions - README with feature documentation - Remove tracked files that belong in gitignore
1604 lines
56 KiB
Vue
1604 lines
56 KiB
Vue
<template>
|
|
<div class="p-6 h-full flex flex-col">
|
|
<!-- Aria-live region for announcements -->
|
|
<div class="sr-only" aria-live="assertive">{{ liveAnnouncement }}</div>
|
|
|
|
<!-- Running timer indicator -->
|
|
<div
|
|
v-if="timerStore.timerState === 'RUNNING'"
|
|
class="flex items-center gap-3 px-4 py-2 mb-4 bg-status-running/10 border border-status-running/20 rounded-lg"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<span class="relative flex h-2 w-2 shrink-0" aria-hidden="true">
|
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-status-running opacity-75" />
|
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-status-running" />
|
|
</span>
|
|
<span class="text-[0.75rem] font-medium text-text-primary">Currently tracking</span>
|
|
<div
|
|
v-if="timerStore.selectedProjectId"
|
|
class="w-2 h-2 rounded-full shrink-0"
|
|
:style="{ backgroundColor: projectsStore.projects.find(p => p.id === timerStore.selectedProjectId)?.color || '#6B7280' }"
|
|
aria-hidden="true"
|
|
/>
|
|
<span class="text-[0.75rem] text-text-secondary truncate">{{ projectsStore.projects.find(p => p.id === timerStore.selectedProjectId)?.name || '' }}</span>
|
|
<span class="text-[0.75rem] font-mono text-status-running ml-auto shrink-0">{{ formatTimerDuration(timerStore.elapsedSeconds) }}</span>
|
|
</div>
|
|
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between mb-6">
|
|
<h1 class="text-[1.75rem] font-bold font-[family-name:var(--font-heading)] tracking-tight text-text-primary">Calendar</h1>
|
|
|
|
<div class="flex items-center gap-3">
|
|
<button
|
|
@click="goToToday"
|
|
class="px-3 py-1.5 text-[0.75rem] font-medium border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
|
>
|
|
Today
|
|
</button>
|
|
|
|
<!-- View toggle (Task 29) -->
|
|
<div role="tablist" aria-label="Calendar view" class="flex items-center border border-border-subtle rounded-lg overflow-hidden">
|
|
<button
|
|
v-for="view in (['day', 'week', 'month'] as const)"
|
|
:key="view"
|
|
@click="currentView = view"
|
|
role="tab"
|
|
:aria-selected="currentView === view"
|
|
:tabindex="currentView === view ? 0 : -1"
|
|
class="px-3 py-1.5 text-[0.6875rem] font-medium capitalize transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
|
:class="currentView === view
|
|
? 'bg-accent text-bg-base'
|
|
: 'text-text-secondary hover:text-text-primary hover:bg-bg-elevated'"
|
|
>
|
|
{{ view }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-1">
|
|
<button
|
|
@click="navigatePrev"
|
|
v-tooltip="'Previous ' + currentView"
|
|
class="p-1.5 text-text-tertiary hover:text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
|
:aria-label="'Previous ' + currentView"
|
|
>
|
|
<ChevronLeft class="w-4 h-4" :stroke-width="2" aria-hidden="true" />
|
|
</button>
|
|
<button
|
|
@click="navigateNext"
|
|
v-tooltip="'Next ' + currentView"
|
|
class="p-1.5 text-text-tertiary hover:text-text-secondary rounded-lg hover:bg-bg-elevated transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
|
:aria-label="'Next ' + currentView"
|
|
>
|
|
<ChevronRight class="w-4 h-4" :stroke-width="2" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<span class="text-[0.75rem] text-text-secondary font-medium min-w-[10rem]" role="status" aria-live="polite">
|
|
{{ viewRangeLabel }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Empty state (week view only) -->
|
|
<div v-if="currentView === 'week' && weekIsEmpty" class="flex-1 flex flex-col items-center justify-center py-16">
|
|
<CalendarX class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" aria-hidden="true" />
|
|
<p class="text-sm text-text-secondary mt-4">No events this week</p>
|
|
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Track time or import a calendar to see events here.</p>
|
|
<router-link to="/timer" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
|
|
Go to Timer
|
|
</router-link>
|
|
<button
|
|
v-if="onboardingStore.isVisible"
|
|
@click="tourStore.start(TOURS.timer)"
|
|
class="mt-2 text-[0.6875rem] text-accent-text hover:underline"
|
|
>
|
|
Show me how
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Empty state (day view) -->
|
|
<div v-else-if="currentView === 'day' && dayIsEmpty" class="flex-1 flex flex-col items-center justify-center py-16">
|
|
<CalendarX class="w-12 h-12 text-text-tertiary animate-float" :stroke-width="1.5" aria-hidden="true" />
|
|
<p class="text-sm text-text-secondary mt-4">No events this day</p>
|
|
<p class="text-xs text-text-tertiary mt-2 max-w-xs text-center">Track time or import a calendar to see events here.</p>
|
|
<router-link to="/timer" class="mt-4 px-4 py-2 bg-accent text-bg-base text-xs font-medium rounded-lg hover:bg-accent-hover transition-colors">
|
|
Go to Timer
|
|
</router-link>
|
|
</div>
|
|
|
|
<!-- Week View -->
|
|
<Transition v-else-if="currentView === 'week'" name="fade" mode="out-in">
|
|
<div :key="weekStart.getTime()" class="flex-1 bg-bg-surface rounded-lg border border-border-subtle overflow-hidden flex flex-col min-h-0">
|
|
<div class="sr-only">Navigate to entries with Tab. Press Enter to edit. Use the quick entry shortcut to create new entries.</div>
|
|
<!-- Scrollable hour rows -->
|
|
<div ref="scrollContainer" class="flex-1 overflow-y-auto min-h-0">
|
|
<!-- Day column headers (sticky) -->
|
|
<div class="grid shrink-0 border-b border-border-subtle sticky top-0 z-10 bg-bg-surface" :style="weekGridStyle">
|
|
<!-- Top-left corner (hour gutter) -->
|
|
<div class="w-14 shrink-0 border-r border-border-subtle" />
|
|
<!-- Day headers -->
|
|
<div
|
|
v-for="(day, index) in weekDays"
|
|
:key="index"
|
|
role="columnheader"
|
|
class="px-2 py-2.5 text-center border-r border-border-subtle last:border-r-0"
|
|
:class="isToday(day) ? 'bg-accent/5' : ''"
|
|
>
|
|
<span
|
|
class="text-[0.6875rem] uppercase tracking-[0.08em] font-medium"
|
|
:class="isToday(day) ? 'text-accent-text' : 'text-text-tertiary'"
|
|
>
|
|
{{ formatDayHeader(day) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="grid relative" :style="weekGridStyle">
|
|
<!-- Hour labels column -->
|
|
<div class="w-14 shrink-0 border-r border-border-subtle">
|
|
<div
|
|
v-for="hour in hours"
|
|
:key="hour"
|
|
class="h-12 flex items-start justify-end pr-2 pt-0.5"
|
|
>
|
|
<span class="text-[0.6875rem] text-text-tertiary font-mono leading-none -translate-y-1/2">
|
|
{{ formatHourLabel(hour) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Day columns -->
|
|
<div
|
|
v-for="(day, dayIndex) in weekDays"
|
|
:key="dayIndex"
|
|
class="relative border-r border-border-subtle last:border-r-0"
|
|
:class="isToday(day) ? 'bg-accent/[0.02]' : ''"
|
|
@mousedown="onGridMouseDown($event, dayIndex, 'week')"
|
|
@mousemove="onGridMouseMove($event, dayIndex, 'week')"
|
|
@mouseup="onGridMouseUp"
|
|
>
|
|
<!-- Hour grid lines -->
|
|
<div
|
|
v-for="hour in hours"
|
|
:key="hour"
|
|
class="h-12 border-b border-border-subtle"
|
|
/>
|
|
|
|
<!-- Drag preview rectangle (Task 32) -->
|
|
<div
|
|
v-if="isDragging && dragStart && dragEnd && getDragDayIndex() === dayIndex"
|
|
class="absolute left-0.5 right-0.5 rounded pointer-events-none z-20"
|
|
:style="getDragPreviewStyle()"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<!-- Time entry blocks -->
|
|
<div
|
|
v-for="entry in getEntriesForDay(day)"
|
|
:key="entry.id"
|
|
class="absolute left-0.5 right-0.5 rounded px-1.5 py-1 overflow-hidden border-l-[3px] min-h-[1.25rem] group focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
|
:class="[
|
|
movingEntryId === entry.id ? 'cursor-grabbing opacity-70 z-30' : 'cursor-grab',
|
|
resizingEntryId === entry.id ? 'z-30' : ''
|
|
]"
|
|
:style="getEntryStyle(entry)"
|
|
:aria-label="getEntryTooltip(entry)"
|
|
tabindex="0"
|
|
@mousedown.stop="startMoveEntry($event, entry, dayIndex)"
|
|
@keydown="onEntryKeydown($event, entry)"
|
|
>
|
|
<p class="text-[0.625rem] font-medium leading-tight truncate" :style="{ color: getProjectColor(entry.project_id) }">
|
|
{{ getProjectName(entry.project_id) }}
|
|
</p>
|
|
<p
|
|
v-if="entry.description && entry.duration >= 900"
|
|
class="text-[0.5625rem] text-text-tertiary leading-tight truncate mt-0.5"
|
|
>
|
|
{{ entry.description }}
|
|
</p>
|
|
<p
|
|
v-if="entry.duration >= 1800"
|
|
class="text-[0.5625rem] text-text-tertiary leading-tight truncate mt-0.5"
|
|
>
|
|
{{ formatDuration(entry.duration) }}
|
|
</p>
|
|
<!-- Replay button -->
|
|
<button
|
|
@click.stop="replayEntry(entry)"
|
|
@mousedown.stop
|
|
class="absolute top-0.5 right-0.5 p-0.5 text-text-tertiary hover:text-status-running transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100 z-10 bg-bg-surface/80 rounded"
|
|
aria-label="Start timer with this entry"
|
|
>
|
|
<Play class="h-2.5 w-2.5" :stroke-width="2" fill="currentColor" aria-hidden="true" />
|
|
</button>
|
|
<!-- Resize handle (Task 33) -->
|
|
<div
|
|
@mousedown.stop="startResize($event, entry)"
|
|
role="separator"
|
|
aria-orientation="horizontal"
|
|
:aria-label="'Resize ' + (entry.description || 'entry')"
|
|
class="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize hover:bg-accent/30 transition-colors opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Calendar event blocks -->
|
|
<div
|
|
v-for="evt in getCalendarEventsForDay(day)"
|
|
:key="'cal-' + (evt.id || evt.uid)"
|
|
class="absolute left-0.5 right-0.5 rounded px-1.5 py-1 overflow-hidden border-l-[3px] border-dashed min-h-[1.25rem] pointer-events-none"
|
|
:style="getCalendarEventStyle(evt)"
|
|
:aria-label="(evt.summary || 'Calendar event') + ' at ' + formatCalendarEventTime(evt)"
|
|
>
|
|
<p class="text-[0.625rem] font-medium leading-tight truncate text-[#8B5CF6]">
|
|
{{ evt.summary || 'Calendar Event' }}
|
|
</p>
|
|
<p class="text-[0.5625rem] text-text-tertiary leading-tight truncate mt-0.5">
|
|
{{ formatCalendarEventTime(evt) }}
|
|
<span v-if="evt.location"> - {{ evt.location }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Current time indicator -->
|
|
<div
|
|
v-if="isToday(day) && currentTimeOffset !== null"
|
|
class="absolute left-0 right-0 z-10 pointer-events-none"
|
|
:style="{ top: currentTimeOffset + 'px' }"
|
|
aria-hidden="true"
|
|
>
|
|
<div class="relative">
|
|
<div class="absolute -left-[3px] -top-[3px] w-[7px] h-[7px] rounded-full bg-status-error" />
|
|
<div class="h-[1.5px] bg-status-error w-full" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- SR-only help text for drag-to-create (Task 32) -->
|
|
<div class="sr-only">Use Enter to create an entry at the selected time.</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Day View (Task 30) -->
|
|
<Transition v-else-if="currentView === 'day'" name="fade" mode="out-in">
|
|
<div :key="currentDay" class="flex-1 bg-bg-surface rounded-lg border border-border-subtle overflow-hidden flex flex-col min-h-0">
|
|
<div class="sr-only">Navigate to entries with Tab. Press Enter to edit. Use the quick entry shortcut to create new entries.</div>
|
|
<div ref="dayScrollContainer" class="flex-1 overflow-y-auto min-h-0">
|
|
<!-- Day header (sticky) -->
|
|
<div class="grid shrink-0 border-b border-border-subtle sticky top-0 z-10 bg-bg-surface" :style="dayGridStyle">
|
|
<div class="w-14 shrink-0 border-r border-border-subtle" />
|
|
<div
|
|
role="columnheader"
|
|
class="px-2 py-2.5 text-center"
|
|
:class="isToday(currentDayDate) ? 'bg-accent/5' : ''"
|
|
>
|
|
<span
|
|
class="text-[0.6875rem] uppercase tracking-[0.08em] font-medium"
|
|
:class="isToday(currentDayDate) ? 'text-accent-text' : 'text-text-tertiary'"
|
|
>
|
|
{{ formatDayHeaderLong(currentDayDate) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="grid relative" :style="dayGridStyle">
|
|
<!-- Hour labels column -->
|
|
<div class="w-14 shrink-0 border-r border-border-subtle">
|
|
<div
|
|
v-for="hour in hours"
|
|
:key="hour"
|
|
class="h-12 flex items-start justify-end pr-2 pt-0.5"
|
|
>
|
|
<span class="text-[0.6875rem] text-text-tertiary font-mono leading-none -translate-y-1/2">
|
|
{{ formatHourLabel(hour) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Single day column -->
|
|
<div
|
|
class="relative"
|
|
:class="isToday(currentDayDate) ? 'bg-accent/[0.02]' : ''"
|
|
@mousedown="onGridMouseDown($event, 0, 'day')"
|
|
@mousemove="onGridMouseMove($event, 0, 'day')"
|
|
@mouseup="onGridMouseUp"
|
|
>
|
|
<!-- Hour grid lines -->
|
|
<div
|
|
v-for="hour in hours"
|
|
:key="hour"
|
|
class="h-12 border-b border-border-subtle"
|
|
/>
|
|
|
|
<!-- Drag preview rectangle -->
|
|
<div
|
|
v-if="isDragging && dragStart && dragEnd && getDragDayIndex() === 0"
|
|
class="absolute left-16 right-2 rounded pointer-events-none z-20"
|
|
:style="getDragPreviewStyle()"
|
|
aria-hidden="true"
|
|
/>
|
|
|
|
<!-- Time entry blocks (wider in day view) -->
|
|
<div
|
|
v-for="entry in dayEntries"
|
|
:key="entry.id"
|
|
class="absolute left-16 right-2 rounded px-1.5 py-1 overflow-hidden border-l-[3px] min-h-[1.25rem] group focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
|
:class="[
|
|
movingEntryId === entry.id ? 'cursor-grabbing opacity-70 z-30' : 'cursor-grab',
|
|
resizingEntryId === entry.id ? 'z-30' : ''
|
|
]"
|
|
:style="getEntryStyle(entry)"
|
|
:aria-label="getEntryTooltip(entry)"
|
|
tabindex="0"
|
|
@mousedown.stop="startMoveEntry($event, entry, 0)"
|
|
@keydown="onEntryKeydown($event, entry)"
|
|
>
|
|
<p class="text-[0.625rem] font-medium leading-tight truncate" :style="{ color: getProjectColor(entry.project_id) }">
|
|
{{ getProjectName(entry.project_id) }}
|
|
</p>
|
|
<p
|
|
v-if="entry.description"
|
|
class="text-[0.5625rem] text-text-tertiary leading-tight truncate mt-0.5"
|
|
>
|
|
{{ entry.description }}
|
|
</p>
|
|
<p
|
|
v-if="entry.duration >= 1800"
|
|
class="text-[0.5625rem] text-text-tertiary leading-tight truncate mt-0.5"
|
|
>
|
|
{{ formatDuration(entry.duration) }}
|
|
</p>
|
|
<!-- Resize handle -->
|
|
<div
|
|
@mousedown.stop="startResize($event, entry)"
|
|
role="separator"
|
|
aria-orientation="horizontal"
|
|
:aria-label="'Resize ' + (entry.description || 'entry')"
|
|
class="absolute bottom-0 left-0 right-0 h-1.5 cursor-ns-resize hover:bg-accent/30 transition-colors opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus:opacity-100"
|
|
tabindex="0"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Calendar event blocks -->
|
|
<div
|
|
v-for="evt in dayCalendarEvents"
|
|
:key="'cal-' + (evt.id || evt.uid)"
|
|
class="absolute left-16 right-2 rounded px-1.5 py-1 overflow-hidden border-l-[3px] border-dashed min-h-[1.25rem] pointer-events-none"
|
|
:style="getCalendarEventStyle(evt)"
|
|
:aria-label="(evt.summary || 'Calendar event') + ' at ' + formatCalendarEventTime(evt)"
|
|
>
|
|
<p class="text-[0.625rem] font-medium leading-tight truncate text-[#8B5CF6]">
|
|
{{ evt.summary || 'Calendar Event' }}
|
|
</p>
|
|
<p class="text-[0.5625rem] text-text-tertiary leading-tight truncate mt-0.5">
|
|
{{ formatCalendarEventTime(evt) }}
|
|
<span v-if="evt.location"> - {{ evt.location }}</span>
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Current time indicator -->
|
|
<div
|
|
v-if="isToday(currentDayDate) && currentTimeOffset !== null"
|
|
class="absolute left-0 right-0 z-10 pointer-events-none"
|
|
:style="{ top: currentTimeOffset + 'px' }"
|
|
aria-hidden="true"
|
|
>
|
|
<div class="relative">
|
|
<div class="absolute -left-[3px] -top-[3px] w-[7px] h-[7px] rounded-full bg-status-error" />
|
|
<div class="h-[1.5px] bg-status-error w-full" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sr-only">Use Enter to create an entry at the selected time.</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Month View (Task 31) -->
|
|
<Transition v-else-if="currentView === 'month'" name="fade" mode="out-in">
|
|
<div :key="currentMonth.year + '-' + currentMonth.month" class="flex-1 bg-bg-surface rounded-lg border border-border-subtle overflow-hidden flex flex-col min-h-0">
|
|
<!-- Weekday headers -->
|
|
<div class="grid grid-cols-7 border-b border-border-subtle">
|
|
<div
|
|
v-for="dayName in ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']"
|
|
:key="dayName"
|
|
class="px-2 py-2 text-center text-[0.6875rem] uppercase tracking-[0.08em] font-medium text-text-tertiary border-r border-border-subtle last:border-r-0"
|
|
>
|
|
{{ dayName }}
|
|
</div>
|
|
</div>
|
|
<!-- Month grid -->
|
|
<div class="flex-1 overflow-y-auto min-h-0">
|
|
<div
|
|
v-for="(week, weekIndex) in monthGrid"
|
|
:key="weekIndex"
|
|
class="grid grid-cols-7 border-b border-border-subtle last:border-b-0"
|
|
>
|
|
<div
|
|
v-for="cell in week"
|
|
:key="cell.date"
|
|
class="min-h-[5rem] p-1.5 border-r border-border-subtle last:border-r-0 cursor-pointer transition-colors hover:bg-bg-elevated focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
|
:class="cell.isCurrentMonth ? '' : 'opacity-40'"
|
|
tabindex="0"
|
|
role="button"
|
|
:aria-label="'View ' + cell.date + (cell.totalSeconds > 0 ? ', ' + formatDuration(cell.totalSeconds) + ' tracked' : '')"
|
|
@click="goToDayFromMonth(cell.date)"
|
|
@keydown.enter="goToDayFromMonth(cell.date)"
|
|
>
|
|
<!-- Day number -->
|
|
<div class="flex items-center justify-between mb-1">
|
|
<span
|
|
class="text-[0.6875rem] font-medium w-6 h-6 flex items-center justify-center rounded-full"
|
|
:class="isTodayStr(cell.date)
|
|
? 'bg-accent text-bg-base'
|
|
: cell.isCurrentMonth ? 'text-text-primary' : 'text-text-tertiary'"
|
|
>
|
|
{{ cell.dayNum }}
|
|
</span>
|
|
<span
|
|
v-if="cell.totalSeconds > 0"
|
|
class="text-[0.5625rem] text-text-secondary font-mono"
|
|
>
|
|
{{ formatDurationShort(cell.totalSeconds) }}
|
|
</span>
|
|
</div>
|
|
<!-- Project color dots (up to 3) -->
|
|
<div class="flex flex-wrap gap-0.5">
|
|
<div
|
|
v-for="(proj, pIdx) in getMonthCellProjects(cell).slice(0, 3)"
|
|
:key="pIdx"
|
|
class="flex items-center gap-0.5 max-w-full"
|
|
>
|
|
<span
|
|
class="w-1.5 h-1.5 rounded-full shrink-0"
|
|
:style="{ backgroundColor: proj.color }"
|
|
aria-hidden="true"
|
|
/>
|
|
<span class="text-[0.5rem] text-text-tertiary truncate max-w-[4rem]">{{ proj.name }}</span>
|
|
</div>
|
|
</div>
|
|
<p
|
|
v-if="cell.entries.length > 3"
|
|
class="text-[0.5rem] text-text-tertiary mt-0.5"
|
|
>
|
|
+{{ cell.entries.length - 3 }} more
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
|
|
<!-- Quick-add popover (Task 32) - Teleported to body -->
|
|
<Teleport to="#app">
|
|
<Transition name="fade">
|
|
<div
|
|
v-if="showQuickAdd"
|
|
ref="quickAddRef"
|
|
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] p-4 w-72 z-[9999]"
|
|
:style="quickAddStyle"
|
|
@keydown.escape="cancelQuickAdd"
|
|
>
|
|
<p class="text-[0.75rem] font-medium text-text-primary mb-3">New entry</p>
|
|
<p class="text-[0.625rem] text-text-tertiary mb-3">
|
|
{{ quickAddTimeLabel }}
|
|
</p>
|
|
<div class="space-y-3">
|
|
<div>
|
|
<label class="text-[0.625rem] text-text-secondary font-medium block mb-1">Project</label>
|
|
<AppSelect
|
|
v-model="quickAddProject"
|
|
:options="projectsStore.projects.filter(p => !p.archived)"
|
|
label-key="name"
|
|
value-key="id"
|
|
placeholder="Select project..."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label for="quick-add-desc" class="text-[0.625rem] text-text-secondary font-medium block mb-1">Description</label>
|
|
<input
|
|
id="quick-add-desc"
|
|
ref="quickAddDescRef"
|
|
v-model="quickAddDescription"
|
|
type="text"
|
|
placeholder="What are you working on?"
|
|
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary placeholder-text-tertiary focus:outline-none focus:border-accent transition-colors"
|
|
@keydown.enter="saveQuickAdd"
|
|
/>
|
|
</div>
|
|
<div class="flex items-center justify-end gap-2 pt-1">
|
|
<button
|
|
@click="cancelQuickAdd"
|
|
class="px-3 py-1.5 text-[0.6875rem] font-medium text-text-secondary hover:text-text-primary transition-colors rounded-lg hover:bg-bg-elevated focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
@click="saveQuickAdd"
|
|
:disabled="!quickAddProject"
|
|
class="px-3 py-1.5 text-[0.6875rem] font-medium bg-accent text-bg-base rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-accent"
|
|
>
|
|
Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import { ChevronLeft, ChevronRight, CalendarX, Play } from 'lucide-vue-next'
|
|
import { useRouter } from 'vue-router'
|
|
import { useEntriesStore, type TimeEntry } from '../stores/entries'
|
|
import { useProjectsStore } from '../stores/projects'
|
|
import { useTimerStore } from '../stores/timer'
|
|
import { useOnboardingStore } from '../stores/onboarding'
|
|
import { useTourStore } from '../stores/tour'
|
|
import { useToastStore } from '../stores/toast'
|
|
import { TOURS } from '../utils/tours'
|
|
import { getZoomFactor } from '../utils/dropdown'
|
|
import { useFocusTrap } from '../utils/focusTrap'
|
|
import AppSelect from '../components/AppSelect.vue'
|
|
|
|
interface CalendarEvent {
|
|
id?: number
|
|
source_id: number
|
|
uid?: string
|
|
summary?: string
|
|
start_time?: string
|
|
end_time?: string
|
|
duration?: number
|
|
location?: string
|
|
}
|
|
|
|
interface MonthCell {
|
|
date: string
|
|
dayNum: number
|
|
isCurrentMonth: boolean
|
|
entries: TimeEntry[]
|
|
totalSeconds: number
|
|
}
|
|
|
|
const entriesStore = useEntriesStore()
|
|
const projectsStore = useProjectsStore()
|
|
const timerStore = useTimerStore()
|
|
const onboardingStore = useOnboardingStore()
|
|
const tourStore = useTourStore()
|
|
const toastStore = useToastStore()
|
|
const router = useRouter()
|
|
|
|
function formatTimerDuration(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600)
|
|
const m = Math.floor((seconds % 3600) / 60)
|
|
const s = seconds % 60
|
|
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
|
}
|
|
const calendarEvents = ref<CalendarEvent[]>([])
|
|
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
|
|
|
|
// -- View state (Task 29) --
|
|
const currentView = ref<'day' | 'week' | 'month'>('week')
|
|
|
|
// -- Week state --
|
|
const weekStart = ref(getMonday(new Date()))
|
|
const scrollContainer = ref<HTMLElement | null>(null)
|
|
|
|
// -- Day state (Task 30) --
|
|
const currentDay = ref(formatISODate(new Date()))
|
|
const dayScrollContainer = ref<HTMLElement | null>(null)
|
|
|
|
// -- Month state (Task 31) --
|
|
const currentMonth = ref({ year: new Date().getFullYear(), month: new Date().getMonth() })
|
|
|
|
// -- Drag-to-create state (Task 32) --
|
|
const isDragging = ref(false)
|
|
const dragStart = ref<{ day: number; hour: number; minutes: number } | null>(null)
|
|
const dragEnd = ref<{ day: number; hour: number; minutes: number } | null>(null)
|
|
const dragView = ref<'week' | 'day'>('week')
|
|
const showQuickAdd = ref(false)
|
|
const quickAddProject = ref<number | null>(null)
|
|
const quickAddDescription = ref('')
|
|
const quickAddRef = ref<HTMLElement | null>(null)
|
|
const quickAddDescRef = ref<HTMLInputElement | null>(null)
|
|
const quickAddStyle = ref<Record<string, string>>({})
|
|
|
|
// -- Drag-to-move/resize state (Task 33) --
|
|
const movingEntryId = ref<number | null>(null)
|
|
const resizingEntryId = ref<number | null>(null)
|
|
const dragOffsetMinutes = ref(0)
|
|
const liveAnnouncement = ref('')
|
|
let moveStartY = 0
|
|
let moveOriginalStartTime = ''
|
|
let moveOriginalDuration = 0
|
|
let moveEntry: TimeEntry | null = null
|
|
|
|
// -- Week empty state --
|
|
const weekIsEmpty = computed(() => {
|
|
const hasEntries = weekDays.value.some(day => getEntriesForDay(day).length > 0)
|
|
const hasEvents = weekDays.value.some(day => getCalendarEventsForDay(day).length > 0)
|
|
return !hasEntries && !hasEvents
|
|
})
|
|
|
|
// -- Day empty state --
|
|
const dayIsEmpty = computed(() => {
|
|
return dayEntries.value.length === 0 && dayCalendarEvents.value.length === 0
|
|
})
|
|
|
|
// Hours displayed: full 24h (0..23)
|
|
const HOUR_START = 0
|
|
const HOUR_END = 23
|
|
const hours = Array.from({ length: HOUR_END - HOUR_START + 1 }, (_, i) => HOUR_START + i)
|
|
const HOUR_HEIGHT = 48 // h-12 = 3rem = 48px
|
|
|
|
// Current time tracking
|
|
const currentTimeOffset = ref<number | null>(null)
|
|
let timeInterval: ReturnType<typeof setInterval> | null = null
|
|
|
|
// Grid template: fixed gutter + 7 equal columns (week)
|
|
const weekGridStyle = computed(() => ({
|
|
gridTemplateColumns: '3.5rem repeat(7, 1fr)'
|
|
}))
|
|
|
|
// Grid template: fixed gutter + 1 column (day)
|
|
const dayGridStyle = computed(() => ({
|
|
gridTemplateColumns: '3.5rem 1fr'
|
|
}))
|
|
|
|
// Week days (Monday to Sunday)
|
|
const weekDays = computed(() => {
|
|
const days: Date[] = []
|
|
for (let i = 0; i < 7; i++) {
|
|
const d = new Date(weekStart.value)
|
|
d.setDate(d.getDate() + i)
|
|
days.push(d)
|
|
}
|
|
return days
|
|
})
|
|
|
|
// Current day as Date object
|
|
const currentDayDate = computed(() => {
|
|
const parts = currentDay.value.split('-')
|
|
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]))
|
|
})
|
|
|
|
// Day entries computed (Task 30)
|
|
const dayEntries = computed(() => {
|
|
return entriesStore.entries.filter(e => {
|
|
const entryDate = new Date(e.start_time)
|
|
return formatISODate(entryDate) === currentDay.value
|
|
})
|
|
})
|
|
|
|
// Day calendar events computed (Task 30)
|
|
const dayCalendarEvents = computed(() => {
|
|
return calendarEvents.value.filter(evt => {
|
|
if (!evt.start_time) return false
|
|
const evtDate = new Date(evt.start_time)
|
|
return formatISODate(evtDate) === currentDay.value
|
|
})
|
|
})
|
|
|
|
// -- View range label (Task 29) --
|
|
const viewRangeLabel = computed(() => {
|
|
if (currentView.value === 'week') {
|
|
const start = weekDays.value[0]
|
|
const end = weekDays.value[6]
|
|
const startMonth = start.toLocaleString('en-US', { month: 'short' })
|
|
const endMonth = end.toLocaleString('en-US', { month: 'short' })
|
|
const startDay = start.getDate()
|
|
const endDay = end.getDate()
|
|
const year = end.getFullYear()
|
|
if (startMonth === endMonth) {
|
|
return `${startMonth} ${startDay} - ${endDay}, ${year}`
|
|
}
|
|
return `${startMonth} ${startDay} - ${endMonth} ${endDay}, ${year}`
|
|
} else if (currentView.value === 'day') {
|
|
const d = currentDayDate.value
|
|
return d.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
|
|
} else {
|
|
// month
|
|
const { year, month } = currentMonth.value
|
|
const d = new Date(year, month, 1)
|
|
return d.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
|
}
|
|
})
|
|
|
|
// -- Month grid computed (Task 31) --
|
|
const monthGrid = computed((): MonthCell[][] => {
|
|
const { year, month } = currentMonth.value
|
|
const firstDay = new Date(year, month, 1)
|
|
const lastDay = new Date(year, month + 1, 0)
|
|
|
|
// Start from Monday of the week containing the first day
|
|
const startDate = getMonday(firstDay)
|
|
|
|
// End on Sunday of the week containing the last day
|
|
const endDate = new Date(lastDay)
|
|
const endDow = endDate.getDay()
|
|
if (endDow !== 0) {
|
|
endDate.setDate(endDate.getDate() + (7 - endDow))
|
|
}
|
|
|
|
// Pre-group entries by date for O(n) lookup instead of O(n*m)
|
|
const entriesByDate = new Map<string, TimeEntry[]>()
|
|
for (const e of entriesStore.entries) {
|
|
const dateStr = formatISODate(new Date(e.start_time))
|
|
const arr = entriesByDate.get(dateStr)
|
|
if (arr) arr.push(e)
|
|
else entriesByDate.set(dateStr, [e])
|
|
}
|
|
|
|
const weeks: MonthCell[][] = []
|
|
const cursor = new Date(startDate)
|
|
|
|
while (cursor <= endDate && weeks.length < 6) {
|
|
const week: MonthCell[] = []
|
|
for (let i = 0; i < 7; i++) {
|
|
const dateStr = formatISODate(cursor)
|
|
const cellEntries = entriesByDate.get(dateStr) || []
|
|
const totalSeconds = cellEntries.reduce((sum, e) => sum + e.duration, 0)
|
|
|
|
week.push({
|
|
date: dateStr,
|
|
dayNum: cursor.getDate(),
|
|
isCurrentMonth: cursor.getMonth() === month && cursor.getFullYear() === year,
|
|
entries: cellEntries,
|
|
totalSeconds
|
|
})
|
|
cursor.setDate(cursor.getDate() + 1)
|
|
}
|
|
weeks.push(week)
|
|
}
|
|
|
|
return weeks
|
|
})
|
|
|
|
// -- Navigation (Task 29) --
|
|
function navigatePrev() {
|
|
if (currentView.value === 'week') prevWeek()
|
|
else if (currentView.value === 'day') prevDay()
|
|
else prevMonth()
|
|
}
|
|
|
|
function navigateNext() {
|
|
if (currentView.value === 'week') nextWeek()
|
|
else if (currentView.value === 'day') nextDay()
|
|
else nextMonth()
|
|
}
|
|
|
|
function prevWeek() {
|
|
const d = new Date(weekStart.value)
|
|
d.setDate(d.getDate() - 7)
|
|
weekStart.value = d
|
|
fetchWeekEntries()
|
|
}
|
|
|
|
function nextWeek() {
|
|
const d = new Date(weekStart.value)
|
|
d.setDate(d.getDate() + 7)
|
|
weekStart.value = d
|
|
fetchWeekEntries()
|
|
}
|
|
|
|
function prevDay() {
|
|
const d = new Date(currentDay.value + 'T00:00:00')
|
|
d.setDate(d.getDate() - 1)
|
|
currentDay.value = formatISODate(d)
|
|
fetchDayEntries()
|
|
}
|
|
|
|
function nextDay() {
|
|
const d = new Date(currentDay.value + 'T00:00:00')
|
|
d.setDate(d.getDate() + 1)
|
|
currentDay.value = formatISODate(d)
|
|
fetchDayEntries()
|
|
}
|
|
|
|
function prevMonth() {
|
|
const { year, month } = currentMonth.value
|
|
if (month === 0) {
|
|
currentMonth.value = { year: year - 1, month: 11 }
|
|
} else {
|
|
currentMonth.value = { year, month: month - 1 }
|
|
}
|
|
fetchMonthEntries()
|
|
}
|
|
|
|
function nextMonth() {
|
|
const { year, month } = currentMonth.value
|
|
if (month === 11) {
|
|
currentMonth.value = { year: year + 1, month: 0 }
|
|
} else {
|
|
currentMonth.value = { year, month: month + 1 }
|
|
}
|
|
fetchMonthEntries()
|
|
}
|
|
|
|
function goToToday() {
|
|
const now = new Date()
|
|
if (currentView.value === 'week') {
|
|
weekStart.value = getMonday(now)
|
|
fetchWeekEntries()
|
|
nextTick(() => scrollToCurrentTime())
|
|
} else if (currentView.value === 'day') {
|
|
currentDay.value = formatISODate(now)
|
|
fetchDayEntries()
|
|
nextTick(() => scrollToCurrentTime())
|
|
} else {
|
|
currentMonth.value = { year: now.getFullYear(), month: now.getMonth() }
|
|
fetchMonthEntries()
|
|
}
|
|
}
|
|
|
|
// Navigate from month cell to day view (Task 31)
|
|
function goToDayFromMonth(dateStr: string) {
|
|
currentDay.value = dateStr
|
|
currentView.value = 'day'
|
|
fetchDayEntries()
|
|
}
|
|
|
|
// -- Fetch functions --
|
|
async function fetchWeekEntries() {
|
|
const start = formatISODate(weekDays.value[0])
|
|
const end = formatISODate(weekDays.value[6])
|
|
await Promise.all([
|
|
entriesStore.fetchEntries(start, end),
|
|
fetchCalendarEvents()
|
|
])
|
|
}
|
|
|
|
async function fetchCalendarEvents() {
|
|
let start: string
|
|
let end: string
|
|
if (currentView.value === 'week') {
|
|
start = formatISODate(weekDays.value[0])
|
|
end = formatISODate(weekDays.value[6])
|
|
} else if (currentView.value === 'day') {
|
|
start = currentDay.value
|
|
end = currentDay.value
|
|
} else {
|
|
// month - use full grid range
|
|
const { year, month } = currentMonth.value
|
|
const firstDay = new Date(year, month, 1)
|
|
const lastDay = new Date(year, month + 1, 0)
|
|
const startDate = getMonday(firstDay)
|
|
const endSunday = new Date(lastDay)
|
|
const endDow = endSunday.getDay()
|
|
if (endDow !== 0) endSunday.setDate(endSunday.getDate() + (7 - endDow))
|
|
start = formatISODate(startDate)
|
|
end = formatISODate(endSunday)
|
|
}
|
|
try {
|
|
calendarEvents.value = await invoke<CalendarEvent[]>('get_calendar_events', { startDate: start, endDate: end })
|
|
} catch (e) {
|
|
console.error('Failed to fetch calendar events:', e)
|
|
}
|
|
}
|
|
|
|
async function fetchDayEntries() {
|
|
await Promise.all([
|
|
entriesStore.fetchEntries(currentDay.value, currentDay.value),
|
|
fetchCalendarEvents()
|
|
])
|
|
}
|
|
|
|
async function fetchMonthEntries() {
|
|
const { year, month } = currentMonth.value
|
|
const firstDay = new Date(year, month, 1)
|
|
const lastDay = new Date(year, month + 1, 0)
|
|
const startDate = getMonday(firstDay)
|
|
const endSunday = new Date(lastDay)
|
|
const endDow = endSunday.getDay()
|
|
if (endDow !== 0) endSunday.setDate(endSunday.getDate() + (7 - endDow))
|
|
await Promise.all([
|
|
entriesStore.fetchEntries(formatISODate(startDate), formatISODate(endSunday)),
|
|
fetchCalendarEvents()
|
|
])
|
|
}
|
|
|
|
// Refetch current view entries
|
|
async function refetchCurrentView() {
|
|
if (currentView.value === 'week') await fetchWeekEntries()
|
|
else if (currentView.value === 'day') await fetchDayEntries()
|
|
else await fetchMonthEntries()
|
|
}
|
|
|
|
// -- Entry helpers --
|
|
function getEntriesForDay(day: Date): TimeEntry[] {
|
|
const dayStr = formatISODate(day)
|
|
return entriesStore.entries.filter(entry => {
|
|
const entryDate = new Date(entry.start_time)
|
|
const entryStr = formatISODate(entryDate)
|
|
return entryStr === dayStr
|
|
})
|
|
}
|
|
|
|
function getEntryStyle(entry: TimeEntry): Record<string, string> {
|
|
const start = new Date(entry.start_time)
|
|
const startHour = start.getHours() + start.getMinutes() / 60
|
|
const durationHours = entry.duration / 3600
|
|
|
|
const visibleStart = Math.max(startHour, HOUR_START)
|
|
const visibleEnd = Math.min(startHour + durationHours, HOUR_END + 1)
|
|
|
|
if (visibleEnd <= HOUR_START || visibleStart >= HOUR_END + 1) {
|
|
return { display: 'none' }
|
|
}
|
|
|
|
const topOffset = (visibleStart - HOUR_START) * HOUR_HEIGHT
|
|
const height = Math.max((visibleEnd - visibleStart) * HOUR_HEIGHT, 20)
|
|
|
|
const color = getProjectColor(entry.project_id)
|
|
|
|
return {
|
|
top: `${topOffset}px`,
|
|
height: `${height}px`,
|
|
backgroundColor: hexToRgba(color, 0.15),
|
|
borderLeftColor: color
|
|
}
|
|
}
|
|
|
|
function getEntryTooltip(entry: TimeEntry): string {
|
|
const project = getProjectName(entry.project_id)
|
|
const duration = formatDuration(entry.duration)
|
|
const start = new Date(entry.start_time)
|
|
const time = start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
|
const desc = entry.description ? ` - ${entry.description}` : ''
|
|
return `${project} (${duration}) at ${time}${desc}`
|
|
}
|
|
|
|
// -- Calendar event helpers --
|
|
function getCalendarEventsForDay(day: Date): CalendarEvent[] {
|
|
const dayStr = formatISODate(day)
|
|
return calendarEvents.value.filter(evt => {
|
|
if (!evt.start_time) return false
|
|
const evtDate = new Date(evt.start_time)
|
|
return formatISODate(evtDate) === dayStr
|
|
})
|
|
}
|
|
|
|
function getCalendarEventStyle(evt: CalendarEvent): Record<string, string> {
|
|
if (!evt.start_time) return { display: 'none' }
|
|
|
|
const start = new Date(evt.start_time)
|
|
const startHour = start.getHours() + start.getMinutes() / 60
|
|
|
|
let durationHours = 1
|
|
if (evt.duration && evt.duration > 0) {
|
|
durationHours = evt.duration / 3600
|
|
} else if (evt.end_time) {
|
|
const end = new Date(evt.end_time)
|
|
durationHours = (end.getTime() - start.getTime()) / 3600000
|
|
}
|
|
|
|
const visibleStart = Math.max(startHour, HOUR_START)
|
|
const visibleEnd = Math.min(startHour + durationHours, HOUR_END + 1)
|
|
|
|
if (visibleEnd <= HOUR_START || visibleStart >= HOUR_END + 1) {
|
|
return { display: 'none' }
|
|
}
|
|
|
|
const topOffset = (visibleStart - HOUR_START) * HOUR_HEIGHT
|
|
const height = Math.max((visibleEnd - visibleStart) * HOUR_HEIGHT, 20)
|
|
|
|
return {
|
|
top: `${topOffset}px`,
|
|
height: `${height}px`,
|
|
backgroundColor: 'rgba(139, 92, 246, 0.1)',
|
|
borderLeftColor: '#8B5CF6',
|
|
borderLeftStyle: 'dashed',
|
|
opacity: '0.6'
|
|
}
|
|
}
|
|
|
|
function formatCalendarEventTime(evt: CalendarEvent): string {
|
|
if (!evt.start_time) return ''
|
|
const start = new Date(evt.start_time)
|
|
return start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
|
}
|
|
|
|
// -- Project helpers --
|
|
function getProjectName(projectId: number): string {
|
|
const project = projectsStore.projects.find(p => p.id === projectId)
|
|
return project?.name || 'Unknown Project'
|
|
}
|
|
|
|
function getProjectColor(projectId: number): string {
|
|
const project = projectsStore.projects.find(p => p.id === projectId)
|
|
return project?.color || '#6B7280'
|
|
}
|
|
|
|
// -- Month cell project helpers --
|
|
function getMonthCellProjects(cell: MonthCell): { name: string; color: string }[] {
|
|
const seen = new Set<number>()
|
|
const result: { name: string; color: string }[] = []
|
|
for (const entry of cell.entries) {
|
|
if (!seen.has(entry.project_id)) {
|
|
seen.add(entry.project_id)
|
|
result.push({
|
|
name: getProjectName(entry.project_id),
|
|
color: getProjectColor(entry.project_id)
|
|
})
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// -- Format helpers --
|
|
function formatDuration(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600)
|
|
const m = Math.floor((seconds % 3600) / 60)
|
|
if (h > 0) {
|
|
return `${h}h ${m}m`
|
|
}
|
|
return `${m}m`
|
|
}
|
|
|
|
function formatDurationShort(seconds: number): string {
|
|
const h = Math.floor(seconds / 3600)
|
|
const m = Math.floor((seconds % 3600) / 60)
|
|
if (h > 0 && m > 0) return `${h}h${m}m`
|
|
if (h > 0) return `${h}h`
|
|
return `${m}m`
|
|
}
|
|
|
|
function replayEntry(entry: TimeEntry) {
|
|
if (!timerStore.isStopped) {
|
|
toastStore.info('Timer is already running. Stop it first.')
|
|
return
|
|
}
|
|
timerStore.setProject(entry.project_id)
|
|
timerStore.setTask(entry.task_id || null)
|
|
timerStore.setDescription(entry.description || '')
|
|
timerStore.setBillable(entry.billable ?? 1)
|
|
timerStore.start()
|
|
router.push('/timer')
|
|
}
|
|
|
|
// -- Date helpers --
|
|
function getMonday(date: Date): Date {
|
|
const d = new Date(date)
|
|
const day = d.getDay()
|
|
const diff = day === 0 ? -6 : 1 - day
|
|
d.setDate(d.getDate() + diff)
|
|
d.setHours(0, 0, 0, 0)
|
|
return d
|
|
}
|
|
|
|
function formatISODate(date: Date): string {
|
|
const y = date.getFullYear()
|
|
const m = String(date.getMonth() + 1).padStart(2, '0')
|
|
const d = String(date.getDate()).padStart(2, '0')
|
|
return `${y}-${m}-${d}`
|
|
}
|
|
|
|
function isToday(date: Date): boolean {
|
|
const today = new Date()
|
|
return formatISODate(date) === formatISODate(today)
|
|
}
|
|
|
|
function isTodayStr(dateStr: string): boolean {
|
|
return dateStr === formatISODate(new Date())
|
|
}
|
|
|
|
function formatDayHeader(date: Date): string {
|
|
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
return `${dayNames[date.getDay()]} ${date.getDate()}`
|
|
}
|
|
|
|
function formatDayHeaderLong(date: Date): string {
|
|
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
|
const monthNames = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
return `${dayNames[date.getDay()]}, ${monthNames[date.getMonth()]} ${date.getDate()}`
|
|
}
|
|
|
|
function formatHourLabel(hour: number): string {
|
|
if (hour === 0) return '12am'
|
|
if (hour === 12) return '12pm'
|
|
if (hour < 12) return `${hour}am`
|
|
return `${hour - 12}pm`
|
|
}
|
|
|
|
function hexToRgba(hex: string, alpha: number): string {
|
|
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
|
if (!result) return `rgba(107, 114, 128, ${alpha})`
|
|
const r = parseInt(result[1], 16)
|
|
const g = parseInt(result[2], 16)
|
|
const b = parseInt(result[3], 16)
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`
|
|
}
|
|
|
|
// -- Current time indicator --
|
|
function updateCurrentTime() {
|
|
const now = new Date()
|
|
const currentHour = now.getHours() + now.getMinutes() / 60
|
|
|
|
if (currentHour >= HOUR_START && currentHour <= HOUR_END + 1) {
|
|
currentTimeOffset.value = (currentHour - HOUR_START) * HOUR_HEIGHT
|
|
} else {
|
|
currentTimeOffset.value = null
|
|
}
|
|
}
|
|
|
|
function scrollToCurrentTime() {
|
|
const container = currentView.value === 'day' ? dayScrollContainer.value : scrollContainer.value
|
|
if (!container) return
|
|
const now = new Date()
|
|
const currentHour = now.getHours()
|
|
const targetHour = Math.max(currentHour - 1, HOUR_START)
|
|
const scrollTop = (targetHour - HOUR_START) * HOUR_HEIGHT
|
|
container.scrollTop = scrollTop
|
|
}
|
|
|
|
// -- Drag-to-create (Task 32) --
|
|
function getMouseHourMinute(event: MouseEvent): { hour: number; minutes: number } {
|
|
const target = event.currentTarget as HTMLElement
|
|
const rect = target.getBoundingClientRect()
|
|
const y = event.clientY - rect.top
|
|
const totalMinutes = (y / HOUR_HEIGHT) * 60
|
|
// Snap to 15-minute increments, clamp to 0-1440 (24h)
|
|
const snapped = Math.min(Math.max(Math.round(totalMinutes / 15) * 15, 0), 24 * 60)
|
|
const hour = Math.min(Math.floor(snapped / 60), 23)
|
|
const minutes = hour === 23 ? Math.min(snapped - 23 * 60, 45) : snapped % 60
|
|
return { hour, minutes }
|
|
}
|
|
|
|
function onGridMouseDown(event: MouseEvent, dayIndex: number, view: 'week' | 'day') {
|
|
// Only left mouse button, and not on an entry
|
|
if (event.button !== 0) return
|
|
if (movingEntryId.value || resizingEntryId.value) return
|
|
|
|
const { hour, minutes } = getMouseHourMinute(event)
|
|
isDragging.value = true
|
|
dragView.value = view
|
|
dragStart.value = { day: dayIndex, hour, minutes }
|
|
dragEnd.value = { day: dayIndex, hour, minutes }
|
|
}
|
|
|
|
function onGridMouseMove(event: MouseEvent, dayIndex: number, _view: 'week' | 'day') {
|
|
if (!isDragging.value || !dragStart.value) return
|
|
if (movingEntryId.value || resizingEntryId.value) return
|
|
|
|
const { hour, minutes } = getMouseHourMinute(event)
|
|
dragEnd.value = { day: dayIndex, hour, minutes }
|
|
}
|
|
|
|
function onGridMouseUp() {
|
|
if (!isDragging.value || !dragStart.value || !dragEnd.value) {
|
|
isDragging.value = false
|
|
return
|
|
}
|
|
|
|
// Check if there's meaningful drag distance (at least 15 min)
|
|
const startMin = dragStart.value.hour * 60 + dragStart.value.minutes
|
|
const endMin = dragEnd.value.hour * 60 + dragEnd.value.minutes
|
|
if (Math.abs(endMin - startMin) < 15) {
|
|
isDragging.value = false
|
|
dragStart.value = null
|
|
dragEnd.value = null
|
|
return
|
|
}
|
|
|
|
isDragging.value = false
|
|
openQuickAdd()
|
|
}
|
|
|
|
function getDragDayIndex(): number {
|
|
return dragStart.value?.day ?? -1
|
|
}
|
|
|
|
function getDragPreviewStyle(): Record<string, string> {
|
|
if (!dragStart.value || !dragEnd.value) return { display: 'none' }
|
|
|
|
const startMin = dragStart.value.hour * 60 + dragStart.value.minutes
|
|
const endMin = dragEnd.value.hour * 60 + dragEnd.value.minutes
|
|
const topMin = Math.min(startMin, endMin)
|
|
const bottomMin = Math.max(startMin, endMin)
|
|
|
|
const top = (topMin / 60) * HOUR_HEIGHT
|
|
const height = Math.max(((bottomMin - topMin) / 60) * HOUR_HEIGHT, 12)
|
|
|
|
return {
|
|
top: `${top}px`,
|
|
height: `${height}px`,
|
|
backgroundColor: 'var(--color-accent)',
|
|
opacity: '0.2',
|
|
borderRadius: '4px'
|
|
}
|
|
}
|
|
|
|
function openQuickAdd() {
|
|
showQuickAdd.value = true
|
|
quickAddProject.value = null
|
|
quickAddDescription.value = ''
|
|
|
|
nextTick(() => {
|
|
if (quickAddRef.value) {
|
|
// Position near the drag area
|
|
const zoom = getZoomFactor()
|
|
const style: Record<string, string> = {
|
|
position: 'fixed',
|
|
zIndex: '9999',
|
|
}
|
|
|
|
// Position at center of viewport
|
|
const vpW = window.innerWidth
|
|
const vpH = window.innerHeight
|
|
style.top = `${(vpH / 2 - 120) / zoom}px`
|
|
style.left = `${(vpW / 2 - 144) / zoom}px`
|
|
|
|
quickAddStyle.value = style
|
|
|
|
activateTrap(quickAddRef.value, {
|
|
onDeactivate: cleanupQuickAddState
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// Quick-add time label
|
|
const quickAddTimeLabel = computed(() => {
|
|
if (!dragStart.value || !dragEnd.value) return ''
|
|
const startMin = dragStart.value.hour * 60 + dragStart.value.minutes
|
|
const endMin = dragEnd.value.hour * 60 + dragEnd.value.minutes
|
|
const topMin = Math.min(startMin, endMin)
|
|
const bottomMin = Math.max(startMin, endMin)
|
|
|
|
const formatTime = (min: number) => {
|
|
const h = Math.floor(min / 60)
|
|
const m = min % 60
|
|
const ampm = h >= 12 ? 'pm' : 'am'
|
|
const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h
|
|
return `${h12}:${String(m).padStart(2, '0')}${ampm}`
|
|
}
|
|
|
|
return `${formatTime(topMin)} - ${formatTime(bottomMin)} (${bottomMin - topMin}min)`
|
|
})
|
|
|
|
function cleanupQuickAddState() {
|
|
showQuickAdd.value = false
|
|
dragStart.value = null
|
|
dragEnd.value = null
|
|
}
|
|
|
|
function cancelQuickAdd() {
|
|
deactivateTrap()
|
|
cleanupQuickAddState()
|
|
}
|
|
|
|
async function saveQuickAdd() {
|
|
if (!quickAddProject.value || !dragStart.value || !dragEnd.value) return
|
|
|
|
const startMin = dragStart.value.hour * 60 + dragStart.value.minutes
|
|
const endMin = dragEnd.value.hour * 60 + dragEnd.value.minutes
|
|
const topMin = Math.min(startMin, endMin)
|
|
const bottomMin = Math.max(startMin, endMin)
|
|
const durationSeconds = (bottomMin - topMin) * 60
|
|
|
|
// Determine the date for this entry
|
|
let entryDate: string
|
|
if (dragView.value === 'day') {
|
|
entryDate = currentDay.value
|
|
} else {
|
|
const dayDate = weekDays.value[dragStart.value.day]
|
|
entryDate = formatISODate(dayDate)
|
|
}
|
|
|
|
const startHour = Math.floor(topMin / 60)
|
|
const startMinute = topMin % 60
|
|
const entryDateObj = new Date(entryDate + 'T00:00:00')
|
|
entryDateObj.setHours(startHour, startMinute, 0, 0)
|
|
const startTime = entryDateObj.toISOString()
|
|
|
|
try {
|
|
await entriesStore.createEntry({
|
|
project_id: quickAddProject.value,
|
|
description: quickAddDescription.value || undefined,
|
|
start_time: startTime,
|
|
duration: durationSeconds,
|
|
billable: 0
|
|
})
|
|
cancelQuickAdd()
|
|
await refetchCurrentView()
|
|
toastStore.success('Entry created')
|
|
} catch (e) {
|
|
toastStore.error('Failed to create entry')
|
|
}
|
|
}
|
|
|
|
// -- Drag-to-move (Task 33) --
|
|
function startMoveEntry(event: MouseEvent, entry: TimeEntry, _dayIndex: number) {
|
|
if (event.button !== 0 || !entry.id) return
|
|
event.preventDefault()
|
|
|
|
movingEntryId.value = entry.id
|
|
moveEntry = { ...entry }
|
|
moveStartY = event.clientY
|
|
moveOriginalStartTime = entry.start_time
|
|
moveOriginalDuration = entry.duration
|
|
dragOffsetMinutes.value = 0
|
|
|
|
document.addEventListener('mousemove', onDocumentMouseMove)
|
|
document.addEventListener('mouseup', onDocumentMouseUp)
|
|
}
|
|
|
|
function onDocumentMouseMove(event: MouseEvent) {
|
|
if (movingEntryId.value && moveEntry) {
|
|
const deltaY = event.clientY - moveStartY
|
|
const deltaMinutes = Math.round((deltaY / HOUR_HEIGHT) * 60 / 15) * 15
|
|
if (deltaMinutes !== dragOffsetMinutes.value) {
|
|
dragOffsetMinutes.value = deltaMinutes
|
|
const origStart = new Date(moveOriginalStartTime)
|
|
origStart.setMinutes(origStart.getMinutes() + deltaMinutes)
|
|
const idx = entriesStore.entries.findIndex(e => e.id === movingEntryId.value)
|
|
if (idx !== -1) {
|
|
entriesStore.entries[idx] = {
|
|
...entriesStore.entries[idx],
|
|
start_time: origStart.toISOString()
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if (resizingEntryId.value && moveEntry) {
|
|
const deltaY = event.clientY - moveStartY
|
|
const deltaMinutes = Math.round((deltaY / HOUR_HEIGHT) * 60 / 15) * 15
|
|
if (deltaMinutes !== dragOffsetMinutes.value) {
|
|
dragOffsetMinutes.value = deltaMinutes
|
|
const newDuration = Math.max(moveOriginalDuration + deltaMinutes * 60, 900)
|
|
const idx = entriesStore.entries.findIndex(e => e.id === resizingEntryId.value)
|
|
if (idx !== -1) {
|
|
entriesStore.entries[idx] = {
|
|
...entriesStore.entries[idx],
|
|
duration: newDuration
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
function onDocumentMouseUp() {
|
|
document.removeEventListener('mousemove', onDocumentMouseMove)
|
|
document.removeEventListener('mouseup', onDocumentMouseUp)
|
|
|
|
if (movingEntryId.value) {
|
|
finishMoveEntry()
|
|
} else if (resizingEntryId.value) {
|
|
finishResizeEntry()
|
|
}
|
|
}
|
|
|
|
async function finishMoveEntry() {
|
|
if (!moveEntry || !movingEntryId.value) return
|
|
const entryId = movingEntryId.value
|
|
|
|
if (dragOffsetMinutes.value === 0) {
|
|
movingEntryId.value = null
|
|
moveEntry = null
|
|
return
|
|
}
|
|
|
|
const origStart = new Date(moveOriginalStartTime)
|
|
origStart.setMinutes(origStart.getMinutes() + dragOffsetMinutes.value)
|
|
|
|
const idx = entriesStore.entries.findIndex(e => e.id === entryId)
|
|
if (idx === -1) {
|
|
movingEntryId.value = null
|
|
moveEntry = null
|
|
return
|
|
}
|
|
|
|
const updatedEntry = {
|
|
...entriesStore.entries[idx],
|
|
start_time: origStart.toISOString()
|
|
}
|
|
|
|
movingEntryId.value = null
|
|
moveEntry = null
|
|
|
|
try {
|
|
await entriesStore.updateEntry(updatedEntry)
|
|
liveAnnouncement.value = `Entry moved to ${origStart.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`
|
|
await refetchCurrentView()
|
|
} catch (e) {
|
|
// Restore original state immediately
|
|
const restoreIdx = entriesStore.entries.findIndex(en => en.id === entryId)
|
|
if (restoreIdx !== -1) {
|
|
entriesStore.entries[restoreIdx] = {
|
|
...entriesStore.entries[restoreIdx],
|
|
start_time: moveOriginalStartTime
|
|
}
|
|
}
|
|
toastStore.error('Failed to move entry')
|
|
await refetchCurrentView()
|
|
}
|
|
}
|
|
|
|
// -- Drag-to-resize (Task 33) --
|
|
function startResize(event: MouseEvent, entry: TimeEntry) {
|
|
if (event.button !== 0 || !entry.id) return
|
|
event.preventDefault()
|
|
|
|
resizingEntryId.value = entry.id
|
|
moveEntry = { ...entry }
|
|
moveStartY = event.clientY
|
|
moveOriginalStartTime = entry.start_time
|
|
moveOriginalDuration = entry.duration
|
|
dragOffsetMinutes.value = 0
|
|
|
|
document.addEventListener('mousemove', onDocumentMouseMove)
|
|
document.addEventListener('mouseup', onDocumentMouseUp)
|
|
}
|
|
|
|
async function finishResizeEntry() {
|
|
if (!moveEntry || !resizingEntryId.value) return
|
|
const entryId = resizingEntryId.value
|
|
|
|
if (dragOffsetMinutes.value === 0) {
|
|
resizingEntryId.value = null
|
|
moveEntry = null
|
|
return
|
|
}
|
|
|
|
const newDuration = Math.max(moveOriginalDuration + dragOffsetMinutes.value * 60, 900)
|
|
|
|
const idx = entriesStore.entries.findIndex(e => e.id === entryId)
|
|
if (idx === -1) {
|
|
resizingEntryId.value = null
|
|
moveEntry = null
|
|
return
|
|
}
|
|
|
|
const updatedEntry = {
|
|
...entriesStore.entries[idx],
|
|
duration: newDuration
|
|
}
|
|
|
|
resizingEntryId.value = null
|
|
moveEntry = null
|
|
|
|
try {
|
|
await entriesStore.updateEntry(updatedEntry)
|
|
liveAnnouncement.value = `Entry resized to ${formatDuration(newDuration)}`
|
|
await refetchCurrentView()
|
|
} catch (e) {
|
|
// Restore original duration immediately
|
|
const restoreIdx = entriesStore.entries.findIndex(en => en.id === entryId)
|
|
if (restoreIdx !== -1) {
|
|
entriesStore.entries[restoreIdx] = {
|
|
...entriesStore.entries[restoreIdx],
|
|
duration: moveOriginalDuration
|
|
}
|
|
}
|
|
toastStore.error('Failed to resize entry')
|
|
await refetchCurrentView()
|
|
}
|
|
}
|
|
|
|
// -- Keyboard move/resize on entry blocks (Task 33) --
|
|
function onEntryKeydown(event: KeyboardEvent, entry: TimeEntry) {
|
|
if (!entry.id) return
|
|
|
|
if (event.key === 'Enter') {
|
|
// Open quick-add at this entry's time position
|
|
event.preventDefault()
|
|
const start = new Date(entry.start_time)
|
|
const startMinutes = start.getHours() * 60 + start.getMinutes()
|
|
const endMinutes = startMinutes + Math.floor(entry.duration / 60)
|
|
|
|
// Determine day index
|
|
let dayIndex = 0
|
|
if (currentView.value === 'week') {
|
|
const entryDateStr = formatISODate(start)
|
|
dayIndex = weekDays.value.findIndex(d => formatISODate(d) === entryDateStr)
|
|
if (dayIndex < 0) dayIndex = 0
|
|
}
|
|
|
|
dragView.value = currentView.value === 'day' ? 'day' : 'week'
|
|
dragStart.value = { day: dayIndex, hour: Math.floor(endMinutes / 60), minutes: endMinutes % 60 }
|
|
dragEnd.value = { day: dayIndex, hour: Math.floor((endMinutes + 60) / 60), minutes: (endMinutes + 60) % 60 }
|
|
openQuickAdd()
|
|
return
|
|
}
|
|
|
|
const isShift = event.shiftKey
|
|
|
|
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
event.preventDefault()
|
|
const delta = event.key === 'ArrowUp' ? -15 : 15
|
|
|
|
if (isShift) {
|
|
// Resize
|
|
resizeEntryByKeyboard(entry, delta)
|
|
} else {
|
|
// Move
|
|
moveEntryByKeyboard(entry, delta)
|
|
}
|
|
}
|
|
}
|
|
|
|
async function moveEntryByKeyboard(entry: TimeEntry, deltaMinutes: number) {
|
|
const start = new Date(entry.start_time)
|
|
const originalDate = formatISODate(start)
|
|
start.setMinutes(start.getMinutes() + deltaMinutes)
|
|
|
|
// Clamp to same day - don't cross midnight
|
|
if (formatISODate(start) !== originalDate) {
|
|
liveAnnouncement.value = 'Cannot move entry past day boundary'
|
|
return
|
|
}
|
|
|
|
const updatedEntry: TimeEntry = {
|
|
...entry,
|
|
start_time: start.toISOString()
|
|
}
|
|
|
|
try {
|
|
await entriesStore.updateEntry(updatedEntry)
|
|
liveAnnouncement.value = `Entry moved to ${start.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`
|
|
await refetchCurrentView()
|
|
} catch (e) {
|
|
toastStore.error('Failed to move entry')
|
|
}
|
|
}
|
|
|
|
async function resizeEntryByKeyboard(entry: TimeEntry, deltaMinutes: number) {
|
|
const newDuration = Math.max(entry.duration + deltaMinutes * 60, 900) // min 15 min
|
|
|
|
const updatedEntry: TimeEntry = {
|
|
...entry,
|
|
duration: newDuration
|
|
}
|
|
|
|
try {
|
|
await entriesStore.updateEntry(updatedEntry)
|
|
liveAnnouncement.value = `Entry duration changed to ${formatDuration(newDuration)}`
|
|
await refetchCurrentView()
|
|
} catch (e) {
|
|
toastStore.error('Failed to resize entry')
|
|
}
|
|
}
|
|
|
|
// -- Watch view changes to fetch data --
|
|
watch(currentView, (newView) => {
|
|
if (newView === 'week') {
|
|
fetchWeekEntries()
|
|
nextTick(() => scrollToCurrentTime())
|
|
} else if (newView === 'day') {
|
|
fetchDayEntries()
|
|
nextTick(() => scrollToCurrentTime())
|
|
} else {
|
|
fetchMonthEntries()
|
|
}
|
|
})
|
|
|
|
// -- Lifecycle --
|
|
onMounted(async () => {
|
|
await Promise.all([
|
|
projectsStore.fetchProjects(),
|
|
fetchWeekEntries()
|
|
])
|
|
|
|
updateCurrentTime()
|
|
timeInterval = setInterval(updateCurrentTime, 60_000)
|
|
|
|
nextTick(() => scrollToCurrentTime())
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (timeInterval) {
|
|
clearInterval(timeInterval)
|
|
timeInterval = null
|
|
}
|
|
document.removeEventListener('mousemove', onDocumentMouseMove)
|
|
document.removeEventListener('mouseup', onDocumentMouseUp)
|
|
})
|
|
</script>
|