Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ Format: weekly entries grouped by feature area.

---

## 2026-04-21 — Calendar Sidebar Typed Indicator Dots

### Added
- Show typed indicator dots on the right-sidebar mini-calendar when viewing the Calendar tab. Each day cell renders up to 3 side-by-side colored dots, one per item, with colors matching the item's visual type: purple for events, green for imported events, blue for tasks, pink for reminders, orange for snoozes. Days with more items than available slots prioritize diversity — one dot per unique type first, then filling remaining slots with duplicates. For example, a day with 3 events, 2 tasks, and 1 snooze shows one purple, one blue, and one orange dot rather than three purple dots. The Journal tab keeps its single emerald/amber activity-intensity dot unchanged, so switching tabs visibly changes what the sidebar tells you.

---

## 2026-04-20 — Calendar Sync Triggers

### Fixed
Expand Down
Original file line number Diff line number Diff line change
@@ -1,27 +1,21 @@
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { AlarmClock, Calendar, CalendarDays, CheckSquare3, NotificationSnooze } from '@/lib/icons'
import { getEventBaseColor, getEventBgColor, getEventTextColor } from '@/lib/event-type-colors'
import { formatTimeOfDay } from '@/lib/time-format'
import type { ClockFormat } from '@/lib/time-format'
import { cn } from '@/lib/utils'
import type { CalendarProjectionItem } from '@/services/calendar-service'
import type { AnchorRect } from './types'

const CHIP_STYLES: Record<CalendarProjectionItem['visualType'], string> = {
event:
'border-[#D8B4FE] bg-[#FAF5FF] text-violet-800 dark:border-violet-500/30 dark:bg-violet-950/30 dark:text-violet-200',
task: 'border-[#BEDBFF] bg-[#EFF6FF] text-blue-800 dark:border-blue-500/30 dark:bg-blue-950/30 dark:text-blue-200',
reminder:
'border-[#B9F8CF] bg-[#F0FDF4] text-green-800 dark:border-green-500/30 dark:bg-green-950/30 dark:text-green-200',
snooze:
'border-[#FFD6A7] bg-[#FFF7ED] text-orange-800 dark:border-orange-500/30 dark:bg-orange-950/30 dark:text-orange-200',
external_event: 'border-border bg-surface text-muted-foreground'
}

const INVERTED_CHIP_STYLES: Record<CalendarProjectionItem['visualType'], string> = {
event: 'bg-[#9810FA] text-white dark:bg-[#C4B5FD] dark:text-[#1a1625]',
task: 'bg-[#155DFC] text-white dark:bg-[#93C5FD] dark:text-[#051833]',
reminder: 'bg-[#FCCEE8] text-white dark:bg-[#FCA5A5] dark:text-[#5c1a2f]',
snooze: 'bg-[#F54900] text-white dark:bg-[#FDBA74] dark:text-[#6b2e0f]',
external_event: 'bg-[#00A63E] text-white dark:bg-[#86EFAC] dark:text-[#051a0a]'
const VISUAL_TYPE_ICONS: Record<
CalendarProjectionItem['visualType'],
React.ComponentType<{ className?: string }>
> = {
event: CalendarDays,
task: CheckSquare3,
reminder: AlarmClock,
snooze: NotificationSnooze,
external_event: Calendar
}

interface CalendarItemChipProps {
Expand All @@ -44,11 +38,24 @@ export function CalendarItemChip({
onDeleteItem
}: CalendarItemChipProps): React.JSX.Element {
const timeLabel = item.isAllDay ? 'All day' : formatTimeOfDay(new Date(item.startAt), clockFormat)
const VisualIcon = VISUAL_TYPE_ICONS[item.visualType]
const deletable = Boolean(onDeleteItem) && canDeleteEvent(item)
const cls = cn(
'flex h-full w-full items-start justify-between gap-0.5 rounded-[6px] border px-1 py-0.5 text-left transition-colors @xl:px-2 @xl:py-1',
isSelected ? INVERTED_CHIP_STYLES[item.visualType] : CHIP_STYLES[item.visualType],
(onClick || deletable) && 'cursor-pointer hover:brightness-95'
'flex h-full w-full items-start justify-between gap-0.5 rounded-[6px] px-1 py-0.5 text-left transition-[filter] @xl:px-2 @xl:py-1',
(onClick || deletable) && 'cursor-pointer hover:brightness-100'
)
const chipStyle = useMemo<React.CSSProperties>(
() =>
isSelected
? {
backgroundColor: getEventBaseColor(item.visualType),
color: '#FFFFFF'
}
: {
backgroundColor: getEventBgColor(item.visualType),
color: getEventTextColor(item.visualType)
},
[item.visualType, isSelected]
)

const handleContextMenu = useCallback(
Expand All @@ -69,6 +76,7 @@ export function CalendarItemChip({

const content = (
<>
<VisualIcon className="mt-0.5 size-3 shrink-0" />
<span className="flex-1 truncate text-xs font-semibold leading-[18px]">{item.title}</span>
<span className="hidden shrink-0 text-xs leading-[18px] opacity-75 @xl:inline">
{timeLabel}
Expand All @@ -81,6 +89,7 @@ export function CalendarItemChip({
<button
type="button"
className={cls}
style={chipStyle}
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect()
onClick?.(item, {
Expand All @@ -99,7 +108,7 @@ export function CalendarItemChip({
}

return (
<div className={cls} data-visual-type={item.visualType}>
<div className={cls} style={chipStyle} data-visual-type={item.visualType}>
{content}
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,14 @@ import {
toLocalDateKey,
toLocalDateString
} from './date-utils'
import { EVENT_TYPE_COLORS } from '@/lib/event-type-colors'
import type { CalendarProjectionItem } from '@/services/calendar-service'
import type { CalendarWorkspaceView } from './calendar-toolbar'
import type { AnchorRect } from './types'

const DAY_HEADERS = ['M', 'T', 'W', 'T', 'F', 'S', 'S']
const CLICK_DELAY_MS = 250

const DOT_COLORS: Record<CalendarProjectionItem['visualType'], string> = {
event: 'bg-violet-400',
task: 'bg-blue-400',
reminder: 'bg-green-400',
snooze: 'bg-orange-400',
external_event: 'bg-neutral-400'
}

function formatPopoverDate(day: string): string {
return new Intl.DateTimeFormat(undefined, {
weekday: 'long',
Expand Down Expand Up @@ -227,8 +220,10 @@ export function CalendarYearView({
}}
>
<span
className={cn('size-2 shrink-0 rounded-full', DOT_COLORS[item.visualType])}
style={item.source.color ? { backgroundColor: item.source.color } : undefined}
className="size-2 shrink-0 rounded-full"
style={{
backgroundColor: item.source.color ?? EVENT_TYPE_COLORS[item.visualType]
}}
/>
<span className="flex-1 truncate text-xs text-foreground">{item.title}</span>
<span className="shrink-0 text-xs text-muted-foreground">
Expand Down
126 changes: 126 additions & 0 deletions apps/desktop/src/renderer/src/components/calendar/day-dots.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'

import type { CalendarProjectionVisualType } from '@/services/calendar-service'

import { buildDayDots, type DayDotsInput } from './day-dots'
import { VISUAL_TYPE_META } from './visual-type-meta'

function item(visualType: CalendarProjectionVisualType, startAt: string): DayDotsInput {
return { visualType, startAt }
}

const color = (type: CalendarProjectionVisualType): string => VISUAL_TYPE_META[type].dotColor

describe('buildDayDots', () => {
it('returns an empty object for no items', () => {
// #given
const items: DayDotsInput[] = []
// #when
const result = buildDayDots(items)
// #then
expect(result).toEqual({})
})

it('renders a single dot for one event on one day', () => {
// #given
const items = [item('event', '2026-04-20T10:00:00.000Z')]
// #when
const result = buildDayDots(items)
// #then
expect(result).toEqual({ '2026-04-20': [color('event')] })
})

it('orders 2 tasks + 1 event as [event, task, task] by VISUAL_TYPE_ORDER', () => {
// #given
const items = [
item('task', '2026-04-20T09:00:00.000Z'),
item('task', '2026-04-20T15:00:00.000Z'),
item('event', '2026-04-20T12:00:00.000Z')
]
// #when
const result = buildDayDots(items)
// #then
expect(result['2026-04-20']).toEqual([color('event'), color('task'), color('task')])
})

it('caps at 3 dots and drops lower-priority items when a day has 5 mixed items', () => {
// #given
const items = [
item('snooze', '2026-04-20T08:00:00.000Z'),
item('task', '2026-04-20T09:00:00.000Z'),
item('event', '2026-04-20T10:00:00.000Z'),
item('external_event', '2026-04-20T11:00:00.000Z'),
item('reminder', '2026-04-20T12:00:00.000Z')
]
// #when
const result = buildDayDots(items)
// #then
expect(result['2026-04-20']).toEqual([color('event'), color('external_event'), color('task')])
expect(result['2026-04-20']).toHaveLength(3)
})

it('prefers uniqueness over count: 3 events + 2 tasks + 1 snooze renders one of each type', () => {
// #given
const items = [
item('event', '2026-04-20T08:00:00.000Z'),
item('event', '2026-04-20T09:00:00.000Z'),
item('event', '2026-04-20T10:00:00.000Z'),
item('task', '2026-04-20T11:00:00.000Z'),
item('task', '2026-04-20T12:00:00.000Z'),
item('snooze', '2026-04-20T13:00:00.000Z')
]
// #when
const result = buildDayDots(items)
// #then
expect(result['2026-04-20']).toEqual([color('event'), color('task'), color('snooze')])
})

it('fills remaining slots with duplicates when fewer than 3 unique types exist', () => {
// #given — 5 tasks, 0 other types
const items = [
item('task', '2026-04-20T08:00:00.000Z'),
item('task', '2026-04-20T09:00:00.000Z'),
item('task', '2026-04-20T10:00:00.000Z'),
item('task', '2026-04-20T11:00:00.000Z'),
item('task', '2026-04-20T12:00:00.000Z')
]
// #when
const result = buildDayDots(items)
// #then
expect(result['2026-04-20']).toEqual([color('task'), color('task'), color('task')])
})

it('buckets items into separate days via local date key, not UTC', () => {
// #given — in America/New_York (UTC-4 in April), 2026-04-20T23:00Z is 19:00 local
// and 2026-04-21T01:00Z is 21:00 local the same day.
// In UTC they are on different days; locally they are the same day.
const items = [
item('event', '2026-04-20T23:00:00.000Z'),
item('task', '2026-04-21T01:00:00.000Z')
]
// #when
const result = buildDayDots(items)
// #then — both items bucket to whichever local date the runner is in.
// Assert: at least one bucket exists, total dots across all buckets equals 2,
// and buckets use YYYY-MM-DD keys.
const buckets = Object.values(result).flat()
expect(buckets).toHaveLength(2)
for (const key of Object.keys(result)) {
expect(key).toMatch(/^\d{4}-\d{2}-\d{2}$/)
}
})

it('produces independent buckets for two distinct days', () => {
// #given
const items = [
item('event', '2026-04-20T12:00:00.000Z'),
item('task', '2026-04-22T12:00:00.000Z')
]
// #when
const result = buildDayDots(items)
// #then
expect(Object.keys(result).sort()).toEqual(['2026-04-20', '2026-04-22'])
expect(result['2026-04-20']).toEqual([color('event')])
expect(result['2026-04-22']).toEqual([color('task')])
})
})
54 changes: 54 additions & 0 deletions apps/desktop/src/renderer/src/components/calendar/day-dots.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { CalendarProjectionVisualType } from '@/services/calendar-service'

import { toLocalDateKey } from './date-utils'
import { VISUAL_TYPE_META, VISUAL_TYPE_ORDER } from './visual-type-meta'

const MAX_DOTS_PER_DAY = 3

export interface DayDotsInput {
visualType: CalendarProjectionVisualType
startAt: string
}

interface RankedItem {
visualType: CalendarProjectionVisualType
intraTypeRank: number
}

function rankItemsWithinTypes(bucket: readonly DayDotsInput[]): RankedItem[] {
const seenCount = new Map<CalendarProjectionVisualType, number>()
return bucket.map((entry) => {
const intraTypeRank = seenCount.get(entry.visualType) ?? 0
seenCount.set(entry.visualType, intraTypeRank + 1)
return { visualType: entry.visualType, intraTypeRank }
})
}

function pickDotsForBucket(bucket: readonly DayDotsInput[]): string[] {
const ranked = rankItemsWithinTypes(bucket)
ranked.sort((a, b) => {
if (a.intraTypeRank !== b.intraTypeRank) return a.intraTypeRank - b.intraTypeRank
return VISUAL_TYPE_ORDER.indexOf(a.visualType) - VISUAL_TYPE_ORDER.indexOf(b.visualType)
})
return ranked
.slice(0, MAX_DOTS_PER_DAY)
.map((entry) => VISUAL_TYPE_META[entry.visualType].dotColor)
}

export function buildDayDots(items: readonly DayDotsInput[]): Record<string, string[]> {
if (items.length === 0) return {}

const bucketed: Record<string, DayDotsInput[]> = {}
for (const entry of items) {
const key = toLocalDateKey(entry.startAt)
const existing = bucketed[key]
bucketed[key] = existing ? [...existing, entry] : [entry]
}

const result: Record<string, string[]> = {}
for (const [key, bucket] of Object.entries(bucketed)) {
result[key] = pickDotsForBucket(bucket)
}

return result
}
Original file line number Diff line number Diff line change
@@ -1,41 +1,37 @@
import { EVENT_TYPE_COLORS } from '@/lib/event-type-colors'
import type { CalendarProjectionVisualType } from '@/services/calendar-service'

interface VisualTypeMeta {
label: string
swatchColor: string
chipClassName: string
dotColor: string
}

export const VISUAL_TYPE_META: Record<CalendarProjectionVisualType, VisualTypeMeta> = {
event: {
label: 'Event',
swatchColor: '#FAF5FF',
chipClassName:
'bg-[#FAF5FF] text-[#9810FA] border border-[#E9D4FF] dark:bg-[#6A34C821] dark:text-[#C4B5FD] dark:border-[#A78BFA47]'
swatchColor: EVENT_TYPE_COLORS.event,
dotColor: EVENT_TYPE_COLORS.event
},
external_event: {
label: 'Imported event',
swatchColor: '#F0FDF4',
chipClassName:
'bg-[#F0FDF4] text-[#00A63E] border border-[#B9F8CF] dark:bg-[#4ADE801A] dark:text-[#86EFAC] dark:border-[#4ADE803D]'
swatchColor: EVENT_TYPE_COLORS.external_event,
dotColor: EVENT_TYPE_COLORS.external_event
},
task: {
label: 'Task',
swatchColor: '#EFF6FF',
chipClassName:
'bg-[#EFF6FF] text-[#155DFC] border border-[#BEDBFF] dark:bg-[#60A5FA1A] dark:text-[#93C5FD] dark:border-[#60A5FA38]'
swatchColor: EVENT_TYPE_COLORS.task,
dotColor: EVENT_TYPE_COLORS.task
},
reminder: {
label: 'Reminder',
swatchColor: '#FDF2F8',
chipClassName:
'bg-[#FDF2F8] text-[#FCCEE8] border border-[#FCCEE8] dark:bg-[#EF44441A] dark:text-[#FCA5A5] dark:border-[#EF44443D]'
swatchColor: EVENT_TYPE_COLORS.reminder,
dotColor: EVENT_TYPE_COLORS.reminder
},
snooze: {
label: 'Snooze',
swatchColor: '#FFF7ED',
chipClassName:
'bg-[#FFF7ED] text-[#F54900] border border-[#FFD6A7] dark:bg-[#FB923C1A] dark:text-[#FDBA74] dark:border-[#FB923C3D]'
swatchColor: EVENT_TYPE_COLORS.snooze,
dotColor: EVENT_TYPE_COLORS.snooze
}
}

Expand Down
Loading
Loading