feat: mark favicon with a red dot while time tracking is active

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NWfQvx8fzZoE7ozLk8RWRf
This commit is contained in:
Claude 2026-06-18 10:32:59 +00:00
parent 37a34cc5cf
commit b11d04726f
No known key found for this signature in database
2 changed files with 90 additions and 0 deletions

View File

@ -61,6 +61,7 @@ import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
import {useColorScheme} from '@/composables/useColorScheme'
import {useTimeTrackingFavicon} from '@/composables/useTimeTrackingFavicon'
import {useBodyClass} from '@/composables/useBodyClass'
import QuickAddOverlay from '@/components/quick-actions/QuickAddOverlay.vue'
import AddToHomeScreen from '@/components/home/AddToHomeScreen.vue'
@ -107,6 +108,7 @@ watch(accountDeletionConfirm, async (accountDeletionConfirm) => {
setLanguage(authStore.settings.language ?? DEFAULT_LANGUAGE)
useColorScheme()
useTimeTrackingFavicon()
</script>
<style src="@/styles/tailwind.css" />

View File

@ -0,0 +1,88 @@
import {watch} from 'vue'
import {createSharedComposable, tryOnMounted} from '@vueuse/core'
import {storeToRefs} from 'pinia'
import {useTimeTrackingStore} from '@/stores/timeTracking'
const FAVICON_SIZE = 32
// Drawn from a PNG rather than the .ico because ICO decoding into a canvas is
// unreliable across browsers.
const BASE_FAVICON = '/images/icons/favicon-32x32.png'
const DOT_COLOR = '#ff4136'
function getFaviconLink(): HTMLLinkElement | null {
return document.querySelector<HTMLLinkElement>('link[rel="icon"]')
}
// Marks the favicon with a small red dot in the lower left corner while a timer
// is running, so an active time tracking session is visible even when the tab
// isn't focused.
export const useTimeTrackingFavicon = createSharedComposable(() => {
const {hasActiveTimer} = storeToRefs(useTimeTrackingStore())
const link = getFaviconLink()
const originalHref = link?.getAttribute('href') ?? '/favicon.ico'
let baseImage: HTMLImageElement | null = null
function loadBaseImage(): Promise<HTMLImageElement> {
if (baseImage?.complete) {
return Promise.resolve(baseImage)
}
return new Promise((resolve, reject) => {
const img = new Image()
img.addEventListener('load', () => {
baseImage = img
resolve(img)
})
img.addEventListener('error', reject)
img.src = BASE_FAVICON
})
}
async function drawBadgedFavicon() {
const targetLink = getFaviconLink()
if (targetLink === null) {
return
}
const img = await loadBaseImage()
const canvas = document.createElement('canvas')
canvas.width = FAVICON_SIZE
canvas.height = FAVICON_SIZE
const ctx = canvas.getContext('2d')
if (ctx === null) {
return
}
ctx.drawImage(img, 0, 0, FAVICON_SIZE, FAVICON_SIZE)
const radius = FAVICON_SIZE * 0.28
const cx = radius
const cy = FAVICON_SIZE - radius
ctx.beginPath()
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
ctx.fillStyle = DOT_COLOR
ctx.fill()
targetLink.href = canvas.toDataURL('image/png')
}
function restoreFavicon() {
const targetLink = getFaviconLink()
if (targetLink !== null) {
targetLink.href = originalHref
}
}
function update(active: boolean) {
if (active) {
void drawBadgedFavicon()
return
}
restoreFavicon()
}
watch(hasActiveTimer, update, {flush: 'post'})
tryOnMounted(() => update(hasActiveTimer.value))
})