Files
zeroclock/src/views/CalendarView.vue
Your Name c4703dfe98 tooltips, two-column timer, font selector, tray behavior, icons, readme
- 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
2026-02-21 01:15:57 +02:00

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>