feat: integrate tags in Timer and Entries views
Timer shows tag selector below description, saves tags on stop. Entries table displays tag chips per row with color coding. Tags loaded from store on mount.
This commit is contained in:
@@ -94,6 +94,16 @@
|
|||||||
<td class="px-4 py-3 text-[0.75rem] text-text-secondary">
|
<td class="px-4 py-3 text-[0.75rem] text-text-secondary">
|
||||||
<span v-if="entry.description" v-html="renderMarkdown(entry.description)" class="markdown-inline" />
|
<span v-if="entry.description" v-html="renderMarkdown(entry.description)" class="markdown-inline" />
|
||||||
<span v-else>-</span>
|
<span v-else>-</span>
|
||||||
|
<div v-if="entryTags[entry.id!]?.length" class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<span
|
||||||
|
v-for="tagId in entryTags[entry.id!]"
|
||||||
|
:key="tagId"
|
||||||
|
class="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full text-[0.5625rem]"
|
||||||
|
:style="{ backgroundColor: getTagColor(tagId) + '22', color: getTagColor(tagId) }"
|
||||||
|
>
|
||||||
|
{{ getTagName(tagId) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text">
|
<td class="px-4 py-3 text-right text-[0.75rem] font-mono text-accent-text">
|
||||||
{{ formatDuration(entry.duration) }}
|
{{ formatDuration(entry.duration) }}
|
||||||
@@ -174,6 +184,12 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Tags</label>
|
||||||
|
<AppTagInput v-model="editEntryTags" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Duration -->
|
<!-- Duration -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Duration (minutes)</label>
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Duration (minutes)</label>
|
||||||
@@ -257,14 +273,19 @@ import AppNumberInput from '../components/AppNumberInput.vue'
|
|||||||
import AppSelect from '../components/AppSelect.vue'
|
import AppSelect from '../components/AppSelect.vue'
|
||||||
import AppDatePicker from '../components/AppDatePicker.vue'
|
import AppDatePicker from '../components/AppDatePicker.vue'
|
||||||
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
|
import AppDiscardDialog from '../components/AppDiscardDialog.vue'
|
||||||
|
import AppTagInput from '../components/AppTagInput.vue'
|
||||||
import { useEntriesStore, type TimeEntry } from '../stores/entries'
|
import { useEntriesStore, type TimeEntry } from '../stores/entries'
|
||||||
import { useProjectsStore } from '../stores/projects'
|
import { useProjectsStore } from '../stores/projects'
|
||||||
|
import { useTagsStore } from '../stores/tags'
|
||||||
import { formatDate } from '../utils/locale'
|
import { formatDate } from '../utils/locale'
|
||||||
import { useFormGuard } from '../utils/formGuard'
|
import { useFormGuard } from '../utils/formGuard'
|
||||||
import { renderMarkdown } from '../utils/markdown'
|
import { renderMarkdown } from '../utils/markdown'
|
||||||
|
|
||||||
const entriesStore = useEntriesStore()
|
const entriesStore = useEntriesStore()
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
|
const tagsStore = useTagsStore()
|
||||||
|
const entryTags = ref<Record<number, number[]>>({})
|
||||||
|
const editEntryTags = ref<number[]>([])
|
||||||
|
|
||||||
const { showDiscardDialog, snapshot: snapshotForm, tryClose: tryCloseForm, confirmDiscard, cancelDiscard } = useFormGuard()
|
const { showDiscardDialog, snapshot: snapshotForm, tryClose: tryCloseForm, confirmDiscard, cancelDiscard } = useFormGuard()
|
||||||
|
|
||||||
@@ -362,6 +383,28 @@ function formatDuration(seconds: number): string {
|
|||||||
return `${minutes}m`
|
return `${minutes}m`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tag helper functions
|
||||||
|
function getTagName(tagId: number): string {
|
||||||
|
const tag = tagsStore.tags.find(t => t.id === tagId)
|
||||||
|
return tag?.name || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTagColor(tagId: number): string {
|
||||||
|
const tag = tagsStore.tags.find(t => t.id === tagId)
|
||||||
|
return tag?.color || '#6B7280'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadEntryTags() {
|
||||||
|
const tags: Record<number, number[]> = {}
|
||||||
|
for (const entry of entriesStore.entries) {
|
||||||
|
if (entry.id) {
|
||||||
|
const entryTagList = await tagsStore.getEntryTags(entry.id)
|
||||||
|
tags[entry.id] = entryTagList.map(t => t.id!).filter(id => id != null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entryTags.value = tags
|
||||||
|
}
|
||||||
|
|
||||||
// Duplicate an entry with current timestamp
|
// Duplicate an entry with current timestamp
|
||||||
async function duplicateEntry(entry: TimeEntry) {
|
async function duplicateEntry(entry: TimeEntry) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -375,6 +418,7 @@ async function duplicateEntry(entry: TimeEntry) {
|
|||||||
}
|
}
|
||||||
await entriesStore.createEntry(newEntry)
|
await entriesStore.createEntry(newEntry)
|
||||||
await entriesStore.fetchEntries()
|
await entriesStore.fetchEntries()
|
||||||
|
await loadEntryTags()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy yesterday's entries to today
|
// Copy yesterday's entries to today
|
||||||
@@ -401,6 +445,7 @@ async function copyPreviousDay() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
await entriesStore.fetchEntries()
|
await entriesStore.fetchEntries()
|
||||||
|
await loadEntryTags()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy last week's entries shifted forward 7 days
|
// Copy last week's entries shifted forward 7 days
|
||||||
@@ -428,23 +473,26 @@ async function copyPreviousWeek() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
await entriesStore.fetchEntries()
|
await entriesStore.fetchEntries()
|
||||||
|
await loadEntryTags()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filters
|
// Apply filters
|
||||||
function applyFilters() {
|
async function applyFilters() {
|
||||||
entriesStore.fetchEntries(startDate.value || undefined, endDate.value || undefined)
|
await entriesStore.fetchEntries(startDate.value || undefined, endDate.value || undefined)
|
||||||
|
await loadEntryTags()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear filters
|
// Clear filters
|
||||||
function clearFilters() {
|
async function clearFilters() {
|
||||||
startDate.value = ''
|
startDate.value = ''
|
||||||
endDate.value = ''
|
endDate.value = ''
|
||||||
filterProject.value = null
|
filterProject.value = null
|
||||||
entriesStore.fetchEntries()
|
await entriesStore.fetchEntries()
|
||||||
|
await loadEntryTags()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open edit dialog
|
// Open edit dialog
|
||||||
function openEditDialog(entry: TimeEntry) {
|
async function openEditDialog(entry: TimeEntry) {
|
||||||
editingEntry.value = entry
|
editingEntry.value = entry
|
||||||
editForm.id = entry.id || 0
|
editForm.id = entry.id || 0
|
||||||
editForm.project_id = entry.project_id
|
editForm.project_id = entry.project_id
|
||||||
@@ -460,6 +508,14 @@ function openEditDialog(entry: TimeEntry) {
|
|||||||
editHour.value = dt.getHours()
|
editHour.value = dt.getHours()
|
||||||
editMinute.value = dt.getMinutes()
|
editMinute.value = dt.getMinutes()
|
||||||
|
|
||||||
|
// Load tags for this entry
|
||||||
|
if (entry.id) {
|
||||||
|
const tags = await tagsStore.getEntryTags(entry.id)
|
||||||
|
editEntryTags.value = tags.map(t => t.id!).filter(id => id != null)
|
||||||
|
} else {
|
||||||
|
editEntryTags.value = []
|
||||||
|
}
|
||||||
|
|
||||||
snapshotForm(getEditFormData())
|
snapshotForm(getEditFormData())
|
||||||
showEditDialog.value = true
|
showEditDialog.value = true
|
||||||
}
|
}
|
||||||
@@ -488,6 +544,13 @@ async function handleEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await entriesStore.updateEntry(updatedEntry)
|
await entriesStore.updateEntry(updatedEntry)
|
||||||
|
|
||||||
|
// Save tags for the edited entry
|
||||||
|
if (editForm.id) {
|
||||||
|
await tagsStore.setEntryTags(editForm.id, editEntryTags.value)
|
||||||
|
await loadEntryTags()
|
||||||
|
}
|
||||||
|
|
||||||
closeEditDialog()
|
closeEditDialog()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -516,9 +579,12 @@ async function handleDelete() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
entriesStore.fetchEntries(),
|
entriesStore.fetchEntries(),
|
||||||
projectsStore.fetchProjects()
|
projectsStore.fetchProjects(),
|
||||||
|
tagsStore.fetchTags()
|
||||||
])
|
])
|
||||||
|
|
||||||
|
await loadEntryTags()
|
||||||
|
|
||||||
// Set default date range to this week
|
// Set default date range to this week
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const weekStart = new Date(now)
|
const weekStart = new Date(now)
|
||||||
|
|||||||
@@ -104,6 +104,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Tags</label>
|
||||||
|
<AppTagInput v-model="selectedTags" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent entries -->
|
<!-- Recent entries -->
|
||||||
@@ -183,10 +187,12 @@ import { useToastStore } from '../stores/toast'
|
|||||||
import { Timer as TimerIcon, RotateCcw, ExternalLink, Star } from 'lucide-vue-next'
|
import { Timer as TimerIcon, RotateCcw, ExternalLink, Star } from 'lucide-vue-next'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import AppSelect from '../components/AppSelect.vue'
|
import AppSelect from '../components/AppSelect.vue'
|
||||||
|
import AppTagInput from '../components/AppTagInput.vue'
|
||||||
import IdlePromptDialog from '../components/IdlePromptDialog.vue'
|
import IdlePromptDialog from '../components/IdlePromptDialog.vue'
|
||||||
import AppTrackingPromptDialog from '../components/AppTrackingPromptDialog.vue'
|
import AppTrackingPromptDialog from '../components/AppTrackingPromptDialog.vue'
|
||||||
import { formatDateTime } from '../utils/locale'
|
import { formatDateTime } from '../utils/locale'
|
||||||
import { useFavoritesStore, type Favorite } from '../stores/favorites'
|
import { useFavoritesStore, type Favorite } from '../stores/favorites'
|
||||||
|
import { useTagsStore } from '../stores/tags'
|
||||||
|
|
||||||
const timerStore = useTimerStore()
|
const timerStore = useTimerStore()
|
||||||
const projectsStore = useProjectsStore()
|
const projectsStore = useProjectsStore()
|
||||||
@@ -194,12 +200,14 @@ const entriesStore = useEntriesStore()
|
|||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
const favoritesStore = useFavoritesStore()
|
const favoritesStore = useFavoritesStore()
|
||||||
|
const tagsStore = useTagsStore()
|
||||||
const favorites = computed(() => favoritesStore.favorites)
|
const favorites = computed(() => favoritesStore.favorites)
|
||||||
|
|
||||||
// Local state for inputs
|
// Local state for inputs
|
||||||
const selectedProject = ref<number | null>(timerStore.selectedProjectId)
|
const selectedProject = ref<number | null>(timerStore.selectedProjectId)
|
||||||
const selectedTask = ref<number | null>(timerStore.selectedTaskId)
|
const selectedTask = ref<number | null>(timerStore.selectedTaskId)
|
||||||
const description = ref(timerStore.description)
|
const description = ref(timerStore.description)
|
||||||
|
const selectedTags = ref<number[]>([])
|
||||||
const projectTasks = ref<Task[]>([])
|
const projectTasks = ref<Task[]>([])
|
||||||
|
|
||||||
// Split timer into parts for colon animation
|
// Split timer into parts for colon animation
|
||||||
@@ -300,7 +308,7 @@ async function openMiniTimer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Toggle timer
|
// Toggle timer
|
||||||
function toggleTimer() {
|
async function toggleTimer() {
|
||||||
if (timerStore.isStopped) {
|
if (timerStore.isStopped) {
|
||||||
if (!selectedProject.value) {
|
if (!selectedProject.value) {
|
||||||
toastStore.info('Please select a project before starting the timer')
|
toastStore.info('Please select a project before starting the timer')
|
||||||
@@ -309,7 +317,16 @@ function toggleTimer() {
|
|||||||
timerStore.start()
|
timerStore.start()
|
||||||
} else {
|
} else {
|
||||||
timerStore.stop()
|
timerStore.stop()
|
||||||
entriesStore.fetchEntries()
|
await entriesStore.fetchEntries()
|
||||||
|
|
||||||
|
// Save tags for the new entry if any were selected
|
||||||
|
if (selectedTags.value.length > 0) {
|
||||||
|
const entries = entriesStore.entries
|
||||||
|
if (entries.length > 0 && entries[0].id) {
|
||||||
|
await tagsStore.setEntryTags(entries[0].id, selectedTags.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selectedTags.value = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +407,8 @@ onMounted(async () => {
|
|||||||
projectsStore.fetchProjects(),
|
projectsStore.fetchProjects(),
|
||||||
entriesStore.fetchEntries(),
|
entriesStore.fetchEntries(),
|
||||||
settingsStore.fetchSettings(),
|
settingsStore.fetchSettings(),
|
||||||
favoritesStore.fetchFavorites()
|
favoritesStore.fetchFavorites(),
|
||||||
|
tagsStore.fetchTags()
|
||||||
])
|
])
|
||||||
|
|
||||||
// Restore timer state
|
// Restore timer state
|
||||||
|
|||||||
Reference in New Issue
Block a user