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:
Your Name
2026-02-18 10:51:39 +02:00
parent 7e7e04e4d4
commit 787f8bbacf
2 changed files with 93 additions and 9 deletions

View File

@@ -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)

View File

@@ -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