Compare commits

..

21 Commits

Author SHA1 Message Date
99f70f3a41 Merge feature/calendar-visual-overhaul into main
Calendar Visual Overhaul:
- en-AU locale with 12-hour time format
- Translucent event styling via CSS custom properties
- Custom eventContent: dot+title in month, title-first in week/day
- Now-indicator pulse dot with prefers-reduced-motion
- Weekend bg neutralised for cross-browser consistency (10+ attempt RCA)
- Per-view dayHeaderFormat (weekday-only in month view)
- Side-by-side event overlap columns in week/day

Backend Performance:
- Starred events scoped to upcoming_days window
- Dashboard queries use materialized calendar ID list

QA: 1 critical (duplicate migration removed), 3 warnings fixed, reviewed clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:02:21 +08:00
050e0c7141 Fix QA findings: remove duplicate migration, formatting, static classNames
- Remove migration 054 (duplicate of 035 which already has all 3 indexes,
  including a superior partial index for starred events)
- Fix handleEventDidMount indentation and missing semicolons
- Replace eventClassNames arrow function with static UMBRA_EVENT_CLASSES array
- Correct misleading subquery comment in dashboard.py

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 01:02:07 +08:00
e12687ca6f Add calendar_events indexes and optimize dashboard queries
Migration 054: three indexes on calendar_events table:
- (calendar_id, start_datetime) for range queries
- (parent_event_id) for recurrence bulk operations
- (calendar_id, is_starred, start_datetime) for starred widget

Dashboard: replaced correlated subquery with single materialized
list fetch for user_calendar_ids in both /dashboard and /upcoming
handlers — eliminates 2 redundant subquery evaluations per request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 00:45:36 +08:00
3e738b18d4 Scope starred events to upcoming_days window
Starred events query had no upper date bound — a starred recurring
event would fill all 5 countdown slots with successive occurrences
beyond the user's configured range. Now capped to upcoming_cutoff_dt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 00:33:23 +08:00
e630832e76 Fix weekend header cells showing different background in Firefox
FC applies its own weekend background to header <th> elements too.
Force weekend header cells to use the same hsl(0 0% 8% / 0.65) as
weekday headers with !important to override FC's built-in styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 21:14:21 +08:00
a0533ee0a7 Remove weekend background tint — cross-browser compositing unreliable
After 10+ attempts, semi-transparent HSL values on near-black backgrounds
produce visible teal artifacts in Firefox due to compositor divergence.
Weekday/weekend frames now use identical --fc-neutral-bg-color. FC's own
weekend td background is neutralised with transparent !important.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:30:08 +08:00
a0ccaaa4bc Reduce weekend frame tint: 10% was too aggressive, use 9% lightness
hsl(0 0% 10% / 0.65) was visibly too bright vs weekday hsl(0 0% 8% / 0.65)
in Firefox. Reduced to hsl(0 0% 9% / 0.65) — 1% bump, subtle but present.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:18:48 +08:00
f5ed64b7da Fix Firefox weekend tint: use absolute HSL values instead of rgba overlay
Firefox composites rgba(255,255,255,0.05) differently against the
fc-daygrid-day-frame's --fc-neutral-bg-color background, producing a
visible mismatch. Switched to absolute HSL values that match the base
pattern:
- Month frame: hsl(0 0% 10% / 0.65) — same alpha as neutral-bg but
  slightly lighter (10% vs 8% lightness)
- Timegrid cols: hsl(0 0% 5.5%) — slightly above page bg (3.9%)

Cross-browser consistent since no alpha compositing is needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:09:57 +08:00
29c91cd706 Fix weekend header mismatch and wrong date format in day headers
Header mismatch: Removed weekend tint from column headers — the white
overlay replaced the standard header bg (hsl 0 0% 8% / 0.65), creating
a non-flush look. Weekend differentiation now comes from body cells only.

Date format: dayHeaderFormat was applied globally, causing month view
headers to show dates like "Sat 10/1" instead of just "Sat". Moved to
per-view formats: month shows weekday only, week shows weekday + d/m,
day shows full weekday + day + month name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 19:57:17 +08:00
d959803985 Fix weekend tint not rendering: replace color-mix() with rgba()
autoprefixer was silently stripping color-mix() during the PostCSS
build pipeline, causing the weekend tint background rules to produce
no output in the deployed CSS bundle. Replaced the three weekend
tint color-mix() calls with equivalent rgba(255,255,255,0.05) which
autoprefixer passes through unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:19:20 +08:00
744fe2c224 Fix calendar weekend tint: target fc-daygrid-day-frame not td
FC6 renders an fc-daygrid-day-frame div inside every daygrid td, painted
with --fc-neutral-bg-color (hsl 0 0% 8% / 0.65). This opaque-ish layer sits
on top of the td background, completely hiding any rgba white overlay applied
to the td itself. Previous attempts set the tint on the td — it was never
visible because the frame covered it.

Fix: apply 5% white color-mix overlay directly to fc-daygrid-day-frame for
month view, and !important on fc-timegrid-col for week/day view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:05:31 +08:00
d9b5868343 Fix weekend tint double-stacking: remove fc-daygrid-day-frame rule
Both the <td> and its child fc-daygrid-day-frame had the 3% white overlay,
causing the frame area to compound to ~6% while td edges stayed at 3%.
This created an uneven "not flush" pattern. The td rule alone is sufficient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:56:30 +08:00
3ead9cd25a Fix weekend tint: replace grayscale with 3% white overlay
RCA finding: grayscale tints are imperceptible on near-black (#0a0a0a)
backgrounds. Deltas of 3-5 RGB units fall below human JND threshold
and OLED panels can clip them to identical output via gamma compression.

Changed from hsl(0 0% 5%) to hsl(0 0% 100% / 0.03) — a semi-transparent
white overlay that composites additively for visible contrast.

See .claude/context/RCA/rca-calendarbg.md for full investigation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:21:24 +08:00
ebeaefe0c5 Fix calendar weekend tint contrast and dot event margin
- Weekend bg raised from hsl(0 0% 2%) to hsl(0 0% 5%) across all 4 rules
  (day cells, col headers, timegrid cols, daygrid-day-frame) so the tint is
  visually distinct against the #0a0a0a page background
- Reduced .fc-daygrid-event-dot margin from default 4px each side to
  0 2px 0 0 on umbra dot events, tightening the gap between dot and title

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 07:50:01 +08:00
e18c94cd83 Fix weekend tint visibility, dot spacing, and event FOUC
Weekend tint: hsl(0 0% 6%) was lighter than page bg #0a0a0a (imperceptible).
Changed to hsl(0 0% 2%) = #050505 for visible darkening. Added rule for
fc-daygrid-day-frame to paint above FC6 internal layers.

Dot spacing: Reduced padding from 1px 4px to 1px 2px for tighter edge gap.

FOUC fix: Moved umbra-event class from eventDidMount (post-paint) to
eventClassNames (synchronous pre-mount). eventDidMount now only sets
the --event-color CSS custom property.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:43:33 +08:00
d6f5975fb9 Add dot indicator to timed month events in custom eventContent
eventContent replaces FC's default inner markup including the dot span.
Render a manual fc-daygrid-event-dot with border-color: var(--event-color).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:35:40 +08:00
40d0bb336c Merge fix: weekend tint cutoff and missing event dots 2026-03-13 02:34:34 +08:00
2a850ad8fd Fix calendar weekend tint cutoff and missing month-view event dots
- index.css: add explicit .fc-col-header-cell.fc-day-sat/sun rules with
  !important to override the generic header background, and cover
  .fc-timegrid-col weekend cells so the tint reaches all views
- CalendarPage.tsx: render .fc-daygrid-event-dot manually in the timed
  month-view eventContent branch — FC's eventContent hook replaces the
  entire default inner content including the dot span, so the CSS target
  had nothing to paint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 02:33:43 +08:00
0e35d473eb Refine calendar events: dot-only month timed, title-first week, no left border
- Month timed events: dot + title only, hover reveals translucent card
- Month all-day events: keep translucent fill
- Time right-aligned in month view (ml-auto)
- Week/day view: title on top, time underneath for better scanning
- Remove 2px left accent border from all events
- Set color:'transparent' on FC event data to prevent inline style conflicts
- Recurring repeat icon preserved in all views

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:24:20 +08:00
dec2c5d526 Fix event colors: remove inline backgroundColor/borderColor from event data
The previous commit failed to remove inline color props due to CRLF line
endings. FullCalendar was still setting inline styles that override CSS.
calendarColor is now correctly in extendedProps for the eventDidMount callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:09:14 +08:00
c473e7e235 Calendar visual overhaul: translucent events, AU locale, typography hierarchy
- Import en-AU locale (object, not string) for proper date format (day/month)
- Add 12-hour time format (9:00 AM), side-by-side overlap columns
- Replace flat opaque event rectangles with translucent color-mix fills (12% opacity)
- Add 2px left accent border per calendar color via CSS custom property
- Implement eventContent render hook with typography hierarchy (time secondary, title primary)
- Add recurring event indicator (Repeat icon) next to recurring event titles
- Add now-indicator 6px pulse dot with prefers-reduced-motion respect
- Add subtle weekend column tint (hsl 0 0% 6%)
- Mobile: hide time spans in month view custom eventContent
- Update +more popover to inherit translucent event styling
- Document calendar event patterns in stylesheet.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 02:02:21 +08:00
3 changed files with 182 additions and 21 deletions

View File

@ -35,8 +35,12 @@ async def get_dashboard(
today = client_date or date.today()
upcoming_cutoff = today + timedelta(days=current_settings.upcoming_days)
# Subquery: calendar IDs belonging to this user (for event scoping)
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
# Fetch calendar IDs once as a plain list — avoids repeating the subquery
# expression in each of the 3+ queries below and makes intent clearer.
calendar_id_result = await db.execute(
select(Calendar.id).where(Calendar.user_id == current_user.id)
)
user_calendar_ids = [row[0] for row in calendar_id_result.all()]
# Today's events (exclude parent templates — they are hidden, children are shown)
today_start = datetime.combine(today, datetime.min.time())
@ -95,11 +99,13 @@ async def get_dashboard(
total_todos = todo_row.total
total_incomplete_todos = todo_row.incomplete
# Starred events (upcoming, ordered by date, scoped to user's calendars)
# Starred events (within upcoming_days window, ordered by date, scoped to user's calendars)
upcoming_cutoff_dt = datetime.combine(upcoming_cutoff, datetime.max.time())
starred_query = select(CalendarEvent).where(
CalendarEvent.calendar_id.in_(user_calendar_ids),
CalendarEvent.is_starred == True,
CalendarEvent.start_datetime > today_start,
CalendarEvent.start_datetime <= upcoming_cutoff_dt,
_not_parent_template,
).order_by(CalendarEvent.start_datetime.asc()).limit(5)
starred_result = await db.execute(starred_query)
@ -171,8 +177,11 @@ async def get_upcoming(
overdue_floor = today - timedelta(days=30)
overdue_floor_dt = datetime.combine(overdue_floor, datetime.min.time())
# Subquery: calendar IDs belonging to this user
user_calendar_ids = select(Calendar.id).where(Calendar.user_id == current_user.id)
# Fetch calendar IDs once as a plain list (same rationale as /dashboard handler)
calendar_id_result = await db.execute(
select(Calendar.id).where(Calendar.user_id == current_user.id)
)
user_calendar_ids = [row[0] for row in calendar_id_result.all()]
# Build queries — include overdue todos (up to 30 days back) and snoozed reminders
todos_query = select(Todo).where(

View File

@ -8,8 +8,9 @@ import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search } from 'lucide-react';
import enAuLocale from '@fullcalendar/core/locales/en-au';
import type { EventClickArg, DateSelectArg, EventDropArg, DatesSetArg, EventContentArg } from '@fullcalendar/core';
import { ChevronLeft, ChevronRight, PanelLeft, Plus, Search, Repeat } from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import axios from 'axios';
import type { CalendarEvent, EventTemplate, Location as LocationType, CalendarPermission } from '@/types';
@ -32,6 +33,8 @@ const viewLabels: Record<CalendarView, string> = {
timeGridDay: 'Day',
};
const UMBRA_EVENT_CLASSES = ['umbra-event'];
export default function CalendarPage() {
const queryClient = useQueryClient();
const location = useLocation();
@ -363,14 +366,14 @@ export default function CalendarPage() {
start: event.start_datetime,
end: event.end_datetime || undefined,
allDay: event.all_day,
backgroundColor: event.calendar_color || 'hsl(var(--accent-color))',
borderColor: event.calendar_color || 'hsl(var(--accent-color))',
color: 'transparent',
editable: permissionMap.get(event.calendar_id) !== 'read_only',
extendedProps: {
is_virtual: event.is_virtual,
is_recurring: event.is_recurring,
parent_event_id: event.parent_event_id,
calendar_id: event.calendar_id,
calendarColor: event.calendar_color || 'hsl(var(--accent-color))',
},
}));
@ -500,6 +503,59 @@ export default function CalendarPage() {
const navigateToday = () => calendarRef.current?.getApi().today();
const changeView = (view: CalendarView) => calendarRef.current?.getApi().changeView(view);
// Set --event-color CSS var via eventDidMount (write-only, no reads)
const handleEventDidMount = useCallback((info: { el: HTMLElement; event: { extendedProps: Record<string, unknown> } }) => {
const color = info.event.extendedProps.calendarColor as string;
if (color) {
info.el.style.setProperty('--event-color', color);
}
}, []);
const renderEventContent = useCallback((arg: EventContentArg) => {
const isMonth = arg.view.type === 'dayGridMonth';
const isAllDay = arg.event.allDay;
const isRecurring = arg.event.extendedProps.is_recurring || arg.event.extendedProps.parent_event_id;
const repeatIcon = isRecurring ? (
<Repeat className="h-2.5 w-2.5 shrink-0 opacity-50" />
) : null;
if (isMonth) {
if (isAllDay) {
return (
<div className="flex items-center gap-1 truncate px-1">
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
{repeatIcon}
</div>
);
}
// Timed events in month: dot + title + time right-aligned
return (
<div className="flex items-center gap-1.5 truncate w-full">
<span
className="fc-daygrid-event-dot"
style={{ borderColor: 'var(--event-color)' }}
/>
<span className="text-[11px] font-medium truncate">{arg.event.title}</span>
{repeatIcon}
<span className="umbra-event-time text-[10px] opacity-50 shrink-0 ml-auto tabular-nums">{arg.timeText}</span>
</div>
);
}
// Week/day view — title on top, time underneath
return (
<div className="flex flex-col overflow-hidden h-full">
<div className="flex items-center gap-1">
<span className="text-[12px] font-medium truncate">{arg.event.title}</span>
{repeatIcon}
</div>
<span className="text-[10px] opacity-50 leading-tight tabular-nums">{arg.timeText}</span>
</div>
);
}, []);
return (
<div className="flex h-full overflow-hidden animate-fade-in">
<div className="hidden lg:flex lg:flex-row shrink-0">
@ -644,6 +700,18 @@ export default function CalendarPage() {
select={handleDateSelect}
datesSet={handleDatesSet}
height="100%"
locale={enAuLocale}
views={{
dayGridMonth: { dayHeaderFormat: { weekday: 'short' } },
timeGridWeek: { dayHeaderFormat: { weekday: 'short', day: 'numeric', month: 'numeric' } },
timeGridDay: { dayHeaderFormat: { weekday: 'long', day: 'numeric', month: 'long' } },
}}
eventTimeFormat={{ hour: 'numeric', minute: '2-digit', meridiem: 'short' }}
slotLabelFormat={{ hour: 'numeric', minute: '2-digit', meridiem: 'short' }}
slotEventOverlap={false}
eventDidMount={handleEventDidMount}
eventClassNames={UMBRA_EVENT_CLASSES}
eventContent={renderEventContent}
/>
</div>
</div>

View File

@ -91,9 +91,9 @@
--fc-button-hover-border-color: hsl(0 0% 20%);
--fc-button-active-bg-color: hsl(var(--accent-color));
--fc-button-active-border-color: hsl(var(--accent-color));
--fc-event-bg-color: hsl(var(--accent-color));
--fc-event-border-color: hsl(var(--accent-color));
--fc-event-text-color: hsl(0 0% 98%);
--fc-event-bg-color: transparent;
--fc-event-border-color: transparent;
--fc-event-text-color: inherit;
--fc-page-bg-color: transparent;
--fc-neutral-bg-color: hsl(0 0% 8% / 0.65);
--fc-neutral-text-color: hsl(0 0% 98%);
@ -144,10 +144,44 @@
border-bottom-color: hsl(var(--accent-color));
}
/* Now indicator pulse dot */
.fc .fc-timegrid-now-indicator-line::before {
content: '';
position: absolute;
left: -3px;
top: -3px;
width: 6px;
height: 6px;
border-radius: 50%;
background: hsl(var(--accent-color));
animation: pulse-dot 2s ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
.fc .fc-timegrid-now-indicator-line::before {
animation: none;
opacity: 1;
}
}
/* Weekend columns: neutralise FullCalendar's built-in weekend td background.
The frame inherits --fc-neutral-bg-color identically to weekdays.
Weekend tint removed after 10+ attempts cross-browser compositing divergence
(Firefox produces teal artifact from semi-transparent HSL on near-black bg). */
.fc .fc-day-sat,
.fc .fc-day-sun {
background-color: transparent !important;
}
.fc .fc-col-header-cell {
background-color: hsl(0 0% 8% / 0.65);
border-color: var(--fc-border-color);
}
.fc .fc-col-header-cell.fc-day-sat,
.fc .fc-col-header-cell.fc-day-sun {
background-color: hsl(0 0% 8% / 0.65) !important;
}
.fc-theme-standard td,
.fc-theme-standard th {
@ -171,21 +205,62 @@
color: hsl(0 0% 63.9%);
}
/* Event pills — compact rounded style */
.fc .fc-daygrid-event {
border-radius: 4px;
/* ── Translucent event styling ── */
.fc .umbra-event {
border: none !important;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
}
.fc .umbra-event .fc-event-main {
color: var(--event-color) !important;
}
/* ── Month view: all-day events get translucent fill ── */
.fc .fc-daygrid-block-event.umbra-event {
background: color-mix(in srgb, var(--event-color) 12%, transparent) !important;
}
.fc .fc-daygrid-block-event.umbra-event:hover {
background: color-mix(in srgb, var(--event-color) 22%, transparent) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
/* ── Month view: timed events — dot only, card on hover ── */
.fc .fc-daygrid-dot-event.umbra-event {
background: transparent !important;
}
.fc .fc-daygrid-dot-event.umbra-event .fc-daygrid-event-dot {
border-color: var(--event-color) !important;
margin: 0 2px 0 0;
}
.fc .fc-daygrid-dot-event.umbra-event:hover {
background: color-mix(in srgb, var(--event-color) 12%, transparent) !important;
}
.fc .fc-daygrid-event.umbra-event {
font-size: 0.75rem;
padding: 0px 4px;
padding: 1px 2px;
line-height: 1.4;
}
.fc .fc-timegrid-event {
border-radius: 4px;
font-size: 0.75rem;
/* ── Time grid (week/day view) — translucent fill ── */
.fc .fc-timegrid-event.umbra-event {
background: color-mix(in srgb, var(--event-color) 12%, transparent) !important;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.fc .fc-timegrid-event .fc-event-main {
padding: 2px 4px;
.fc .fc-timegrid-event.umbra-event:hover {
background: color-mix(in srgb, var(--event-color) 22%, transparent) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
.fc .fc-timegrid-event.umbra-event .fc-event-main {
padding: 3px 6px;
}
/* Day number styling for today */
@ -262,6 +337,10 @@
padding: 2px 6px;
margin-bottom: 2px;
}
/* Ensure translucent events in popover inherit styling */
.fc .fc-more-popover .umbra-event {
margin-bottom: 2px;
}
}
/* ── Chromium native date picker icon fix (safety net) ── */
@ -358,6 +437,11 @@ form[data-submitted] input:invalid + button {
display: none;
}
/* Hide time spans in custom eventContent on mobile month view */
.fc .fc-daygrid-event .umbra-event-time {
display: none;
}
.fc .fc-timegrid-event {
font-size: 0.6rem;
}