From dca9417aeea58952bc8831a2621ba343b0c547c6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:34:58 +0200 Subject: [PATCH] feat: restyle calendar events and toasts to the app design --- src/components/ToastHost.tsx | 293 ++++++++++++++---- .../__tests__/calendar-i18n.test.ts | 1 + src/components/__tests__/toast-swipe.test.ts | 43 +++ src/components/calendar/AgendaView.tsx | 11 +- src/components/calendar/EventBlock.tsx | 169 ++++++++++ src/components/calendar/EventCard.tsx | 52 +++- src/components/calendar/MonthScrollView.tsx | 5 + src/components/calendar/MonthView.tsx | 50 ++- .../calendar/TimeGridScrollView.tsx | 125 ++++---- src/components/calendar/WeekView.tsx | 107 +++---- src/components/toast-swipe.ts | 39 +++ .../__tests__/calendar-participants.test.ts | 30 ++ src/lib/__tests__/event-colors.test.ts | 116 +++++++ src/lib/calendar-participants.ts | 24 ++ src/lib/event-colors.ts | 172 ++++++++++ src/screens/CalendarScreen.tsx | 9 + 16 files changed, 1010 insertions(+), 236 deletions(-) create mode 100644 src/components/__tests__/toast-swipe.test.ts create mode 100644 src/components/calendar/EventBlock.tsx create mode 100644 src/components/toast-swipe.ts create mode 100644 src/lib/__tests__/event-colors.test.ts create mode 100644 src/lib/event-colors.ts diff --git a/src/components/ToastHost.tsx b/src/components/ToastHost.tsx index 5d5f9088..1762398a 100644 --- a/src/components/ToastHost.tsx +++ b/src/components/ToastHost.tsx @@ -1,12 +1,16 @@ import React from 'react'; -import { View, Text, StyleSheet, Pressable, Animated, Easing } from 'react-native'; +import { + View, Text, StyleSheet, Pressable, Animated, Easing, PanResponder, useWindowDimensions, + type AccessibilityActionEvent, type LayoutChangeEvent, +} from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { AlertTriangle, CheckCircle2, Info, X, XCircle } from 'lucide-react-native'; -import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; -import { useColors } from '../theme/colors'; -import { useAnimDuration } from '../theme/dynamic'; +import { AlertCircle, AlertTriangle, CheckCircle2, Info } from 'lucide-react-native'; +import { spacing, radius, type ThemePalette } from '../theme/tokens'; +import { useColors, useResolvedTheme } from '../theme/colors'; +import { useShouldAnimate } from '../theme/dynamic'; import { useToastStore, type Toast } from '../stores/toast-store'; import { useLocaleStore } from '../stores/locale-store'; +import { shouldClaimToastSwipe, toastSwipeRelease } from './toast-swipe'; /** * Renders the toast queue above the tab bar. App.tsx gives every stack screen @@ -25,70 +29,196 @@ export function ToastHost(): React.ReactElement | null { ); } +const ENTER_MS = 200; +const EXIT_MS = 160; + +// Icons and text in a status colour (repos/branding/APP.md): the palette's +// error and warning fills are too light for an icon on a light ground. +const STATUS_ICON = { + light: { error: '#dc2626', warning: '#d97706' }, + dark: { error: '#f87171', warning: '#fbbf24' }, +} as const; + +/** + * The toast card from repos/branding/APP.md: a plain floating card whose icon + * and countdown line carry the status. No close button on the phone; a + * sideways swipe dismisses it. + */ function ToastCard({ toast }: { toast: Toast }) { const c = useColors(); - const cardStyles = React.useMemo(() => makeStyles(c), [c]); + const scheme = useResolvedTheme(); + const cardStyles = React.useMemo(() => makeStyles(c, scheme), [c, scheme]); const t = useLocaleStore((s) => s.t); const removeToast = useToastStore((s) => s.removeToast); - const duration = useAnimDuration(200); + const animate = useShouldAnimate(); + const { width: screenWidth } = useWindowDimensions(); + const opacity = React.useRef(new Animated.Value(0)).current; const translateY = React.useRef(new Animated.Value(24)).current; + const translateX = React.useRef(new Animated.Value(0)).current; + // Share of the countdown line still filled, 1 → 0. + const remainingShare = React.useRef(new Animated.Value(1)).current; + const cardWidth = React.useRef(0); React.useEffect(() => { - Animated.parallel([ + const duration = animate ? ENTER_MS : 0; + const enter = Animated.parallel([ Animated.timing(opacity, { toValue: 1, duration, useNativeDriver: true }), Animated.timing(translateY, { toValue: 0, duration, easing: Easing.out(Easing.cubic), useNativeDriver: true }), - ]).start(); + ]); + enter.start(); + return () => enter.stop(); + }, [animate, opacity, translateY]); + + // Leave after the toast's duration, counted from when it was queued (a + // screen change remounts the card). The countdown line runs out with it; + // with animations off it stays full instead of moving. + React.useEffect(() => { const remaining = Math.max(0, toast.duration - (Date.now() - toast.createdAt)); const timer = setTimeout(() => removeToast(toast.id), remaining); - return () => clearTimeout(timer); - }, [toast, opacity, translateY, duration, removeToast]); + if (!animate) { + remainingShare.setValue(1); + return () => clearTimeout(timer); + } + remainingShare.setValue(toast.duration > 0 ? remaining / toast.duration : 0); + const countdown = Animated.timing(remainingShare, { + toValue: 0, + duration: remaining, + easing: Easing.linear, + useNativeDriver: true, + }); + countdown.start(); + return () => { + clearTimeout(timer); + countdown.stop(); + }; + }, [toast, animate, remainingShare, removeToast]); - const accent = toast.type === 'success' - ? c.success - : toast.type === 'error' - ? c.error - : toast.type === 'warning' - ? c.warning - : c.info; + // The PanResponder is built once; its handlers read these through a ref. + const latest = React.useRef({ animate, screenWidth, id: toast.id, removeToast }); + latest.current = { animate, screenWidth, id: toast.id, removeToast }; + + const responder = React.useMemo( + () => { + const springBack = () => { + if (!latest.current.animate) { + translateX.setValue(0); + return; + } + Animated.spring(translateX, { toValue: 0, useNativeDriver: true, speed: 24, bounciness: 4 }).start(); + }; + return PanResponder.create({ + onStartShouldSetPanResponder: () => false, + onMoveShouldSetPanResponder: (_, g) => shouldClaimToastSwipe(g), + onPanResponderGrant: () => translateX.stopAnimation(), + onPanResponderMove: (_, g) => translateX.setValue(g.dx), + onPanResponderRelease: (_, g) => { + const direction = toastSwipeRelease(g, cardWidth.current); + if (direction === 0) { + springBack(); + return; + } + const { animate: animated, screenWidth: width, id, removeToast: remove } = latest.current; + Animated.timing(translateX, { + toValue: direction * width, + duration: animated ? EXIT_MS : 0, + useNativeDriver: true, + }).start(() => remove(id)); + }, + onPanResponderTerminate: springBack, + onPanResponderTerminationRequest: () => false, + }); + }, + [translateX], + ); + + // Fades as it is dragged away. + const cardOpacity = React.useMemo( + () => Animated.multiply( + opacity, + translateX.interpolate({ + inputRange: [-screenWidth, 0, screenWidth], + outputRange: [0, 1, 0], + extrapolate: 'clamp', + }), + ), + [opacity, translateX, screenWidth], + ); + + const onLayout = (e: LayoutChangeEvent) => { + cardWidth.current = e.nativeEvent.layout.width; + }; + + const statusIcon = STATUS_ICON[scheme]; + const iconColor = toast.type === 'error' + ? statusIcon.error + : toast.type === 'warning' + ? statusIcon.warning + : c.mutedForeground; + const countdownColor = toast.type === 'error' + ? c.error + : toast.type === 'warning' + ? c.warning + : c.mutedForeground; const Icon = toast.type === 'success' ? CheckCircle2 : toast.type === 'error' - ? XCircle + ? AlertCircle : toast.type === 'warning' ? AlertTriangle : Info; + // Without a close button, screen readers dismiss through an action. + const dismissActions = React.useMemo( + () => [{ name: 'dismiss', label: t('common.dismiss', 'Dismiss') }, { name: 'escape' }], + [t], + ); + const onAccessibilityAction = (e: AccessibilityActionEvent) => { + if (e.nativeEvent.actionName === 'dismiss' || e.nativeEvent.actionName === 'escape') { + removeToast(toast.id); + } + }; + return ( - - - {toast.title} - {toast.message ? {toast.message} : null} - - {toast.action ? ( - { toast.action?.onPress(); removeToast(toast.id); }} - hitSlop={8} - accessibilityRole="button" - style={({ pressed }) => [cardStyles.actionBtn, pressed && cardStyles.actionBtnPressed]} + + + - {toast.action.label} - - ) : null} - removeToast(toast.id)} - hitSlop={8} - accessibilityRole="button" - accessibilityLabel={t('common.dismiss', 'Dismiss')} - style={cardStyles.close} - > - - + {toast.title} + {toast.message ? {toast.message} : null} + + {toast.action ? ( + { toast.action?.onPress(); removeToast(toast.id); }} + hitSlop={8} + accessibilityRole="button" + style={({ pressed }) => [cardStyles.action, pressed && cardStyles.actionPressed]} + > + {/* The underline is a border so it can sit 3px under the + baseline; textDecorationLine has no offset in RN. */} + + {toast.action.label} + + + ) : null} + + + ); } @@ -103,31 +233,62 @@ const styles = StyleSheet.create({ }, }); -function makeStyles(c: ThemePalette) { +const COUNTDOWN_HEIGHT = 2; + +function makeStyles(c: ThemePalette, scheme: 'light' | 'dark') { return StyleSheet.create({ - card: { - flexDirection: 'row', - alignItems: 'center', - gap: spacing.sm, - paddingVertical: spacing.sm + 2, - paddingLeft: spacing.md, - paddingRight: spacing.sm, - borderRadius: radius.md, - borderLeftWidth: 3, + // Carries the shadow; the card inside clips the countdown line to the + // rounded corners, which would clip an iOS shadow on the same view. + shadow: { + borderRadius: radius.sm, backgroundColor: c.popover, - borderWidth: 1, - borderColor: c.border, shadowColor: '#000', - shadowOpacity: 0.25, - shadowRadius: 10, - shadowOffset: { width: 0, height: 4 }, + shadowOpacity: scheme === 'dark' ? 0.4 : 0.1, + shadowRadius: 16, + shadowOffset: { width: 0, height: 6 }, elevation: 6, }, - title: { ...typography.bodyMedium, color: c.text }, - message: { ...typography.caption, color: c.mutedForeground, marginTop: 2 }, - actionBtn: { paddingHorizontal: spacing.sm, paddingVertical: 6, borderRadius: radius.sm }, - actionBtnPressed: { backgroundColor: c.muted }, - actionText: { ...typography.captionMedium }, - close: { padding: 4 }, + card: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: spacing.sm, + paddingTop: 12, + paddingLeft: 14, + paddingRight: 14, + // Room for the countdown line. + paddingBottom: 14, + borderRadius: radius.sm, + borderWidth: 1, + borderColor: c.border, + backgroundColor: c.popover, + overflow: 'hidden', + }, + // Centred on the title's first line. + icon: { marginTop: 1 }, + body: { flex: 1 }, + title: { fontSize: 14.5, lineHeight: 20, fontWeight: '500', color: c.text }, + message: { fontSize: 13, lineHeight: 18, color: c.mutedForeground, marginTop: 2 }, + action: { alignSelf: 'center' }, + actionPressed: { opacity: 0.6 }, + actionUnderline: { borderBottomWidth: 1, borderBottomColor: c.text }, + actionText: { fontSize: 13, lineHeight: 16, fontWeight: '600', color: c.text }, + countdownTrack: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: COUNTDOWN_HEIGHT, + backgroundColor: c.text, + opacity: 0.06, + }, + countdownFill: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + height: COUNTDOWN_HEIGHT, + opacity: 0.6, + transformOrigin: 'left', + }, }); } diff --git a/src/components/__tests__/calendar-i18n.test.ts b/src/components/__tests__/calendar-i18n.test.ts index da636db7..746a9f7b 100644 --- a/src/components/__tests__/calendar-i18n.test.ts +++ b/src/components/__tests__/calendar-i18n.test.ts @@ -11,6 +11,7 @@ const SRC = join(__dirname, '..', '..'); const TRANSLATED_FILES: Record = { 'screens/CalendarScreen.tsx': [], 'components/calendar/AgendaView.tsx': [], + 'components/calendar/EventBlock.tsx': [], 'components/calendar/EventCard.tsx': [], 'components/calendar/MonthScrollView.tsx': [], 'components/calendar/MonthView.tsx': [], diff --git a/src/components/__tests__/toast-swipe.test.ts b/src/components/__tests__/toast-swipe.test.ts new file mode 100644 index 00000000..9f836cdc --- /dev/null +++ b/src/components/__tests__/toast-swipe.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { shouldClaimToastSwipe, toastSwipeRelease } from '../toast-swipe'; + +const g = (dx: number, dy = 0, vx = 0) => ({ dx, dy, vx }); + +describe('shouldClaimToastSwipe', () => { + it('takes sideways drags in either direction', () => { + expect(shouldClaimToastSwipe(g(10))).toBe(true); + expect(shouldClaimToastSwipe(g(-10, 4))).toBe(true); + }); + + it('leaves taps, jitter and vertical moves alone', () => { + expect(shouldClaimToastSwipe(g(3))).toBe(false); + expect(shouldClaimToastSwipe(g(10, 10))).toBe(false); + expect(shouldClaimToastSwipe(g(0, 30))).toBe(false); + }); +}); + +describe('toastSwipeRelease', () => { + it('dismisses past 35% of the card, towards the drag', () => { + expect(toastSwipeRelease(g(120), 300)).toBe(1); + expect(toastSwipeRelease(g(-110), 300)).toBe(-1); + expect(toastSwipeRelease(g(100), 300)).toBe(0); + }); + + it('caps the distance on wide cards', () => { + expect(toastSwipeRelease(g(125), 800)).toBe(1); + expect(toastSwipeRelease(g(115), 800)).toBe(0); + }); + + it('dismisses a short flick', () => { + expect(toastSwipeRelease(g(40, 0, 0.8), 300)).toBe(1); + expect(toastSwipeRelease(g(-40, 0, -0.8), 300)).toBe(-1); + // A flick against the drag does not count. + expect(toastSwipeRelease(g(40, 0, -0.8), 300)).toBe(0); + expect(toastSwipeRelease(g(20, 0, 2), 300)).toBe(0); + }); + + it('does not dismiss a small drag before the card has a width', () => { + expect(toastSwipeRelease(g(20), 0)).toBe(0); + expect(toastSwipeRelease(g(130), 0)).toBe(1); + }); +}); diff --git a/src/components/calendar/AgendaView.tsx b/src/components/calendar/AgendaView.tsx index 2adc37eb..60a63ade 100644 --- a/src/components/calendar/AgendaView.tsx +++ b/src/components/calendar/AgendaView.tsx @@ -47,6 +47,8 @@ interface AgendaViewProps { /** Reports the first day in view as the user scrolls. */ onVisibleDateChange?: (date: Date) => void; timeFormat?: TimeFormat; + /** The user's addresses, to draw events they declined as inactive. */ + currentUserEmails?: string[]; onSelectEvent?: (event: CalendarEvent) => void; } @@ -90,6 +92,7 @@ export function AgendaView({ isLoading = false, onVisibleDateChange, timeFormat, + currentUserEmails, onSelectEvent, }: AgendaViewProps) { const c = useColors(); @@ -324,7 +327,13 @@ export function AgendaView({ }} renderItem={({ item }) => ( - + )} renderSectionFooter={({ section }) => diff --git a/src/components/calendar/EventBlock.tsx b/src/components/calendar/EventBlock.tsx new file mode 100644 index 00000000..46e3df8c --- /dev/null +++ b/src/components/calendar/EventBlock.tsx @@ -0,0 +1,169 @@ +import React from 'react'; +import { Pressable, StyleSheet, Text, View, type StyleProp, type ViewStyle } from 'react-native'; +import type { EventBlockColors } from '../../lib/event-colors'; + +// Events in the week and day grids, shared by the paged WeekView and the +// scrolling TimeGridScrollView. Every event is a solid block of its calendar +// colour with a label colour computed from it; declined and cancelled ones +// are outlined on the page ground with a struck-through title +// (repos/branding/APP.md, "Calendar events"). Callers get the colours from +// eventBlockColors(). + +/** Corner radius of event blocks and bars. */ +export const EVENT_RADIUS = 2; + +/** Timed blocks this long or longer show their start time under the title. */ +export const TIME_LINE_MIN_MINUTES = 45; + +// Corners on a side the event continues past (the day before or after, the +// week before or after) stay square. +function corners( + axis: 'vertical' | 'horizontal', + continuesBefore: boolean, + continuesAfter: boolean, + r: number, +): ViewStyle { + const before = continuesBefore ? 0 : r; + const after = continuesAfter ? 0 : r; + return axis === 'vertical' + ? { + borderTopLeftRadius: before, + borderTopRightRadius: before, + borderBottomLeftRadius: after, + borderBottomRightRadius: after, + } + : { + borderTopLeftRadius: before, + borderBottomLeftRadius: before, + borderTopRightRadius: after, + borderBottomRightRadius: after, + }; +} + +interface TimedEventBlockProps { + title: string; + /** Start time for the second line; null leaves it out. */ + timeLabel: string | null; + colors: EventBlockColors; + /** Page ground: a 1px ring in it keeps overlapping blocks apart. */ + ringColor: string; + /** The event started on an earlier day (square top corners). */ + continuesBefore: boolean; + /** The event runs into the next day (square bottom corners). */ + continuesAfter: boolean; + /** Position and size in the day column. */ + style: StyleProp; + onPress?: () => void; +} + +/** A timed event in a day column. */ +export function TimedEventBlock({ + title, + timeLabel, + colors, + ringColor, + continuesBefore, + continuesAfter, + style, + onPress, +}: TimedEventBlockProps) { + const inactive = colors.border !== null; + return ( + + {inactive && ( + // The calendar-coloured outline sits inside the ring. + + )} + + {title} + + {timeLabel !== null && ( + + {timeLabel} + + )} + + ); +} + +interface AllDayEventBarProps { + title: string; + colors: EventBlockColors; + /** The event runs in from the week before (square left corners). */ + continuesBefore: boolean; + /** The event runs on into the week after (square right corners). */ + continuesAfter: boolean; + /** Position and size in the all-day strip. */ + style: StyleProp; + onPress?: () => void; +} + +/** An all-day or multi-day event in the strip above the grid. */ +export function AllDayEventBar({ + title, + colors, + continuesBefore, + continuesAfter, + style, + onPress, +}: AllDayEventBarProps) { + const inactive = colors.border !== null; + return ( + + + {continuesBefore ? '… ' : ''} + {title} + + + ); +} + +const styles = StyleSheet.create({ + block: { + position: 'absolute', + borderWidth: 1, + paddingHorizontal: 3, + paddingVertical: 1, + overflow: 'hidden', + }, + outline: { ...StyleSheet.absoluteFillObject, borderWidth: 1 }, + blockTitle: { fontSize: 12, lineHeight: 15, fontWeight: '500' }, + blockTime: { fontSize: 10.5, lineHeight: 13, fontWeight: '400', opacity: 0.85 }, + struck: { textDecorationLine: 'line-through' }, + bar: { + position: 'absolute', + paddingHorizontal: 4, + justifyContent: 'center', + overflow: 'hidden', + }, + // The outline takes the place of a pixel of padding. + barInactive: { borderWidth: 1, paddingHorizontal: 3 }, + barTitle: { fontSize: 12, lineHeight: 15, fontWeight: '500' }, +}); diff --git a/src/components/calendar/EventCard.tsx b/src/components/calendar/EventCard.tsx index ddc71ce0..3a461793 100644 --- a/src/components/calendar/EventCard.tsx +++ b/src/components/calendar/EventCard.tsx @@ -12,11 +12,14 @@ import { type TimeFormat, } from '../../lib/calendar-utils'; import { useCalendarLocale } from '../../lib/calendar-locale'; +import { isInactiveEvent } from '../../lib/calendar-participants'; interface EventCardProps { event: CalendarEvent; calendars: Calendar[]; timeFormat?: TimeFormat; + /** The user's addresses, to draw events they declined as inactive. */ + currentUserEmails?: string[]; onPress?: (event: CalendarEvent) => void; onLongPress?: (event: CalendarEvent) => void; } @@ -25,7 +28,12 @@ function participantCount(event: CalendarEvent): number { return event.participants ? Object.keys(event.participants).length : 0; } -export function EventCard({ event, calendars, timeFormat, onPress, onLongPress }: EventCardProps) { +/** + * An event as an agenda row (repos/branding/APP.md): a dot in the calendar + * colour before the title, no card and no bar. Declined and cancelled events + * get a hollow dot and a struck-through, muted title. + */ +export function EventCard({ event, calendars, timeFormat, currentUserEmails, onPress, onLongPress }: EventCardProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const { locale, t } = useCalendarLocale(); @@ -37,18 +45,18 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress } : `${format(start, fmt, { locale })} – ${format(end, fmt, { locale })}`; const count = participantCount(event); const location = event.locations ? Object.values(event.locations)[0]?.name : undefined; - const cancelled = event.status === 'cancelled'; + const inactive = isInactiveEvent(event, currentUserEmails); return ( [styles.card, pressed && styles.cardPressed]} + style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} onPress={() => onPress?.(event)} onLongPress={() => onLongPress?.(event)} > - + - + {event.title || t('calendar.events.no_title', '(No title)')} {event.showWithoutTime && ( @@ -89,21 +97,31 @@ export function EventCard({ event, calendars, timeFormat, onPress, onLongPress } ); } +const EVENT_DOT = 9; + function makeStyles(c: ThemePalette) { return StyleSheet.create({ - card: { + // Bleeds into the list's side padding so the dot lines up with the day + // headers while the pressed highlight keeps some room around the text. + row: { flexDirection: 'row', - backgroundColor: c.card, - borderTopRightRadius: radius.lg, - borderBottomRightRadius: radius.lg, - marginBottom: spacing.sm, - overflow: 'hidden', - borderWidth: 1, - borderColor: c.border, + alignItems: 'flex-start', + gap: spacing.sm, + paddingVertical: spacing.sm, + paddingHorizontal: spacing.sm, + marginHorizontal: -spacing.sm, + borderRadius: radius.sm, }, - cardPressed: { backgroundColor: c.surfaceHover }, - colorBar: { width: 3 }, - body: { flex: 1, padding: spacing.md, gap: 4 }, + rowPressed: { backgroundColor: c.surfaceHover }, + // Centred on the title's line (bodyMedium, 20px). + dot: { + width: EVENT_DOT, + height: EVENT_DOT, + borderRadius: EVENT_DOT / 2, + marginTop: (typography.bodyMedium.lineHeight - EVENT_DOT) / 2, + }, + dotHollow: { borderWidth: 1.5 }, + body: { flex: 1, gap: 4 }, headerRow: { flexDirection: 'row', justifyContent: 'space-between', @@ -111,7 +129,7 @@ function makeStyles(c: ThemePalette) { gap: spacing.sm, }, title: { ...typography.bodyMedium, color: c.text, flex: 1 }, - titleCancelled: { textDecorationLine: 'line-through', color: c.textMuted }, + titleInactive: { textDecorationLine: 'line-through', color: c.mutedForeground }, allDayBadge: { backgroundColor: c.primaryBg, paddingHorizontal: 8, diff --git a/src/components/calendar/MonthScrollView.tsx b/src/components/calendar/MonthScrollView.tsx index 5484273f..275e7895 100644 --- a/src/components/calendar/MonthScrollView.tsx +++ b/src/components/calendar/MonthScrollView.tsx @@ -53,6 +53,8 @@ interface MonthScrollViewProps { showWeekNumbers?: boolean; showTimeInMonthView?: boolean; timeFormat?: TimeFormat; + /** The user's addresses, to draw events they declined as inactive. */ + currentUserEmails?: string[]; /** Widen the window at the top; omitted once the limit is reached. */ onExtendStart?: () => void; /** Widen the window at the bottom; omitted once the limit is reached. */ @@ -83,6 +85,7 @@ function MonthScrollViewInner({ showWeekNumbers = false, showTimeInMonthView = false, timeFormat, + currentUserEmails, onExtendStart, onExtendEnd, onVisibleDateChange, @@ -191,6 +194,7 @@ function MonthScrollViewInner({ showWeekNumbers={showWeekNumbers} showTimeInMonthView={showTimeInMonthView} timeFormat={timeFormat} + currentUserEmails={currentUserEmails} labelMonths height={rowHeight} locale={locale} @@ -209,6 +213,7 @@ function MonthScrollViewInner({ showWeekNumbers, showTimeInMonthView, timeFormat, + currentUserEmails, rowHeight, locale, styles, diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx index 41bf16de..a31ac81a 100644 --- a/src/components/calendar/MonthView.tsx +++ b/src/components/calendar/MonthView.tsx @@ -12,7 +12,7 @@ import { type Locale, } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; -import { componentSizes, radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { componentSizes, spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { buildEventDayIndex, @@ -24,6 +24,8 @@ import { type TimeFormat, } from '../../lib/calendar-utils'; import { dayIndexIn, monthKeyOf, monthMask } from '../../lib/calendar-month-scroll'; +import { isInactiveEvent } from '../../lib/calendar-participants'; +import { eventBlockColors } from '../../lib/event-colors'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { displayNow } from '../../lib/calendar-timezone'; @@ -33,6 +35,8 @@ type WeekStart = 0 | 1 | 6; // scrolling month uses them as fixed row heights. export const MONTH_ROW_HEIGHT = 54; export const MONTH_ROW_HEIGHT_CHIPS = 74; +// Corner radius of event bars (repos/branding/APP.md). +const EVENT_RADIUS = 2; interface MonthViewProps { currentDate: Date; @@ -45,6 +49,8 @@ interface MonthViewProps { // Render compact event chips (title + start time) instead of dots (#666). showTimeInMonthView?: boolean; timeFormat?: TimeFormat; + /** The user's addresses, to draw events they declined as inactive. */ + currentUserEmails?: string[]; onSelectDate: (date: Date) => void; onLongPressDate?: (date: Date) => void; } @@ -101,6 +107,8 @@ export interface MonthWeekRowProps { showWeekNumbers: boolean; showTimeInMonthView: boolean; timeFormat?: TimeFormat; + /** The user's addresses, to draw events they declined as inactive. */ + currentUserEmails?: string[]; /** Mark the first day of a month with the month's name (continuous scrolling). */ labelMonths?: boolean; /** Fixed row height (continuous scrolling); natural height otherwise. */ @@ -122,6 +130,7 @@ function MonthWeekRowInner({ showWeekNumbers, showTimeInMonthView, timeFormat, + currentUserEmails, labelMonths = false, height, locale, @@ -129,6 +138,7 @@ function MonthWeekRowInner({ onSelectDate, onLongPressDate, }: MonthWeekRowProps) { + const c = useColors(); return ( {showWeekNumbers && ( @@ -197,17 +207,24 @@ function MonthWeekRowInner({ {dayEvents.length > 0 && showTimeInMonthView && ( {visible.map((event, idx) => { - const color = getEventColor(event, calendars); - const cancelled = event.status === 'cancelled'; + // Solid bars in the calendar colour; declined and + // cancelled events outlined (repos/branding/APP.md). + const inactive = isInactiveEvent(event, currentUserEmails); + const colors = eventBlockColors(getEventColor(event, calendars), inactive, c); return ( - - + + {event.showWithoutTime - ? (event.title || '') - : `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`} + ? null + : `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `} + {event.title || ''} ); @@ -236,6 +253,7 @@ function MonthViewInner({ showWeekNumbers = false, showTimeInMonthView = false, timeFormat, + currentUserEmails, onSelectDate, onLongPressDate, }: MonthViewProps) { @@ -278,6 +296,7 @@ function MonthViewInner({ showWeekNumbers={showWeekNumbers} showTimeInMonthView={showTimeInMonthView} timeFormat={timeFormat} + currentUserEmails={currentUserEmails} locale={locale} styles={styles} onSelectDate={onSelectDate} @@ -349,12 +368,17 @@ function makeStyles(c: ThemePalette) { }, chipsCol: { width: '100%', gap: 1, marginTop: 2, alignItems: 'stretch' }, chip: { - borderRadius: radius.xs, + borderRadius: EVENT_RADIUS, paddingHorizontal: 2, paddingVertical: 1, }, - chipText: { color: c.textInverse, fontSize: 9, lineHeight: 11 }, - chipTextCancelled: { textDecorationLine: 'line-through' }, + // The 1px outline takes the place of a pixel of padding, so outlined and + // solid bars are the same size. + chipInactive: { borderWidth: 1, paddingHorizontal: 1, paddingVertical: 0 }, + // Colour comes from eventBlockColors(): computed from the calendar colour, + // never a theme colour. + chipText: { fontSize: 9, lineHeight: 11, fontWeight: '500' }, + chipTextInactive: { textDecorationLine: 'line-through' }, overflowText: { color: c.textMuted, fontSize: 9, diff --git a/src/components/calendar/TimeGridScrollView.tsx b/src/components/calendar/TimeGridScrollView.tsx index 3b0cbc02..8ef195be 100644 --- a/src/components/calendar/TimeGridScrollView.tsx +++ b/src/components/calendar/TimeGridScrollView.tsx @@ -17,7 +17,7 @@ import { } from 'react-native'; import { differenceInCalendarDays, format, type Locale } from 'date-fns'; import type { Calendar, CalendarEvent } from '../../api/types'; -import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { @@ -33,6 +33,8 @@ import { } from '../../lib/calendar-utils'; import type { CalendarFocus, DayRange } from '../../lib/calendar-scroll-window'; import { displayNow, displayNowMinutes } from '../../lib/calendar-timezone'; +import { isInactiveEvent } from '../../lib/calendar-participants'; +import { eventBlockColors } from '../../lib/event-colors'; import { buildAllDaySegments, headerColumnRange, @@ -41,6 +43,7 @@ import { windowDays, type TimeGridMode, } from '../../lib/calendar-time-grid'; +import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock'; const HOUR_HEIGHT = 48; const GRID_HEIGHT = 24 * HOUR_HEIGHT; @@ -67,6 +70,8 @@ interface TimeGridScrollViewProps { eventsByDay?: EventDayIndex; weekStartsOn?: WeekStart; timeFormat?: TimeFormat; + /** The user's addresses, to draw events they declined as inactive. */ + currentUserEmails?: string[]; /** Widen the window at the start; omitted once the limit is reached. */ onExtendStart?: () => void; /** Widen the window at the end; omitted once the limit is reached. */ @@ -98,6 +103,7 @@ function TimeGridScrollViewInner({ eventsByDay, weekStartsOn = 0, timeFormat = '24h', + currentUserEmails, onExtendStart, onExtendEnd, onVisibleDateChange, @@ -301,12 +307,17 @@ function TimeGridScrollViewInner({ nowMinutes={dayKey(day) === todayKey ? nowMinutes : -1} calendars={calendars} timeFormat={timeFormat} + currentUserEmails={currentUserEmails} + colors={c} styles={styles} onSelectEvent={onSelectEvent} onLongPressAt={onCreateAtTime ? handleLongPressAt : undefined} /> ), - [layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, styles, onSelectEvent, onCreateAtTime, handleLongPressAt], + [ + layoutsFor, colWidth, todayKey, nowMinutes, calendars, timeFormat, currentUserEmails, c, styles, + onSelectEvent, onCreateAtTime, handleLongPressAt, + ], ); const getItemLayout = React.useCallback( @@ -379,34 +390,26 @@ function TimeGridScrollViewInner({ ]} > {headerCells} - {visibleSegments.map((segment) => { - const color = getEventColor(segment.event, calendars); - return ( - onSelectEvent?.(segment.event)} - style={[ - styles.allDayChip, - { - left: segment.startIndex * colWidth + 1, - width: segment.span * colWidth - 2, - top: HEADER_HEIGHT + 2 + segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP), - backgroundColor: color + '33', - borderLeftColor: color, - borderTopLeftRadius: segment.continuesBefore ? 0 : radius.xs, - borderBottomLeftRadius: segment.continuesBefore ? 0 : radius.xs, - borderTopRightRadius: segment.continuesAfter ? 0 : radius.xs, - borderBottomRightRadius: segment.continuesAfter ? 0 : radius.xs, - }, - ]} - > - - {segment.continuesBefore ? '… ' : ''} - {segment.event.title || t('calendar.events.no_title', '(No title)')} - - - ); - })} + {visibleSegments.map((segment) => ( + onSelectEvent?.(segment.event)} + style={{ + left: segment.startIndex * colWidth + 1, + width: segment.span * colWidth - 2, + top: HEADER_HEIGHT + 2 + segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP), + height: ALL_DAY_CHIP_HEIGHT, + }} + /> + ))} @@ -526,6 +529,8 @@ const DayColumn = React.memo(function DayColumn({ nowMinutes, calendars, timeFormat, + currentUserEmails, + colors, styles, onSelectEvent, onLongPressAt, @@ -537,6 +542,8 @@ const DayColumn = React.memo(function DayColumn({ nowMinutes: number; calendars: Calendar[]; timeFormat: TimeFormat; + currentUserEmails?: string[]; + colors: ThemePalette; styles: GridStyles; onSelectEvent?: (event: CalendarEvent) => void; onLongPressAt?: (day: Date, hour: number) => void; @@ -553,36 +560,28 @@ const DayColumn = React.memo(function DayColumn({ style={[styles.dayCol, { width }]} onLongPress={onLongPressAt ? handleLongPress : undefined} > - {layouts.map(({ event, column, totalColumns, startMinutes, endMinutes }) => { + {layouts.map(({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => { const top = (startMinutes / 60) * HOUR_HEIGHT; const height = Math.max(20, ((endMinutes - startMinutes) / 60) * HOUR_HEIGHT - 1); const widthPct = 100 / totalColumns; - const color = getEventColor(event, calendars); return ( - onSelectEvent?.(event)} - style={[ - styles.eventBlock, - { - top, - height, - left: `${column * widthPct}%`, - width: `${widthPct}%`, - backgroundColor: color + '33', - borderLeftColor: color, - }, - ]} - > - - {event.title || t('calendar.events.no_title', '(No title)')} - - {height > 32 && ( - - {minutesToTimeLabel(startMinutes, timeFormat)} - + title={event.title || t('calendar.events.no_title', '(No title)')} + timeLabel={endMinutes - startMinutes >= TIME_LINE_MIN_MINUTES + ? minutesToTimeLabel(startMinutes, timeFormat) + : null} + colors={eventBlockColors( + getEventColor(event, calendars), + isInactiveEvent(event, currentUserEmails), + colors, )} - + ringColor={colors.background} + continuesBefore={continuesBefore} + continuesAfter={continuesAfter} + onPress={() => onSelectEvent?.(event)} + style={{ top, height, left: `${column * widthPct}%`, width: `${widthPct}%` }} + /> ); })} {nowMinutes >= 0 && ( @@ -649,14 +648,6 @@ function makeStyles(c: ThemePalette) { allDayLabelWrap: { alignItems: 'flex-end', justifyContent: 'flex-start', paddingRight: 4, paddingTop: 2 }, allDayLabel: { ...typography.small, color: c.textMuted, textAlign: 'right' }, - allDayChip: { - position: 'absolute', - height: ALL_DAY_CHIP_HEIGHT, - borderLeftWidth: 2, - paddingHorizontal: 4, - justifyContent: 'center', - }, - allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' }, vScroll: { flex: 1 }, gridRow: { flexDirection: 'row', height: GRID_HEIGHT }, @@ -678,16 +669,6 @@ function makeStyles(c: ThemePalette) { borderLeftWidth: 1, borderLeftColor: c.border, }, - eventBlock: { - position: 'absolute', - borderRadius: radius.xs, - borderLeftWidth: 2, - paddingHorizontal: 3, - paddingVertical: 2, - overflow: 'hidden', - }, - eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' }, - eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 }, nowLine: { position: 'absolute', left: -3, diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx index a35bb40f..ed2f7b28 100644 --- a/src/components/calendar/WeekView.tsx +++ b/src/components/calendar/WeekView.tsx @@ -9,7 +9,7 @@ import { import { addDays, format, isSameDay, startOfWeek } from 'date-fns'; import { displayNow, displayNowMinutes, isDisplayToday } from '../../lib/calendar-timezone'; import type { Calendar, CalendarEvent } from '../../api/types'; -import { radius, spacing, typography, type ThemePalette } from '../../theme/tokens'; +import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useCalendarLocale } from '../../lib/calendar-locale'; import { @@ -24,6 +24,9 @@ import { type CalendarWeekSegment, type EventDayIndex, } from '../../lib/calendar-utils'; +import { isInactiveEvent } from '../../lib/calendar-participants'; +import { eventBlockColors } from '../../lib/event-colors'; +import { AllDayEventBar, TIME_LINE_MIN_MINUTES, TimedEventBlock } from './EventBlock'; const HOUR_HEIGHT = 48; const GUTTER_WIDTH = 44; @@ -43,6 +46,8 @@ interface WeekViewProps { onCreateAtTime?: (date: Date) => void; weekStartsOn?: 0 | 1 | 6; timeFormat?: '12h' | '24h'; + /** The user's addresses, to draw events they declined as inactive. */ + currentUserEmails?: string[]; } function WeekViewInner({ @@ -56,6 +61,7 @@ function WeekViewInner({ onCreateAtTime, weekStartsOn = 0, timeFormat = '24h', + currentUserEmails, }: WeekViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -183,35 +189,28 @@ function WeekViewInner({ {/* Segments overlay - multi-day events span their date range */} {allDaySegments.map((segment) => { - const color = getEventColor(segment.event, calendars); const leftPct = (segment.startIndex / weekDays.length) * 100; const widthPct = (segment.span / weekDays.length) * 100; return ( - onSelectEvent?.(segment.event)} - style={[ - styles.allDayChip, - { - position: 'absolute', - left: `${leftPct}%`, - width: `${widthPct}%`, - top: segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP), - backgroundColor: color + '33', - borderLeftColor: color, - borderTopLeftRadius: segment.continuesBefore ? 0 : radius.xs, - borderBottomLeftRadius: segment.continuesBefore ? 0 : radius.xs, - borderTopRightRadius: segment.continuesAfter ? 0 : radius.xs, - borderBottomRightRadius: segment.continuesAfter ? 0 : radius.xs, - marginHorizontal: 1, - }, - ]} - > - - {segment.continuesBefore ? '… ' : ''} - {segment.event.title || t('calendar.events.no_title', '(No title)')} - - + style={{ + left: `${leftPct}%`, + width: `${widthPct}%`, + top: segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP), + height: ALL_DAY_CHIP_HEIGHT, + marginHorizontal: 1, + }} + /> ); })} @@ -255,7 +254,7 @@ function WeekViewInner({ ))} {layouted.map( - ({ event, column, totalColumns, startMinutes, endMinutes }) => { + ({ event, column, totalColumns, startMinutes, endMinutes, continuesBefore, continuesAfter }) => { const top = (startMinutes / 60) * HOUR_HEIGHT; const height = Math.max( 20, @@ -263,32 +262,24 @@ function WeekViewInner({ ); const widthPct = 100 / totalColumns; const leftPct = column * widthPct; - const color = getEventColor(event, calendars); return ( - onSelectEvent?.(event)} - style={[ - styles.eventBlock, - { - top, - height, - left: `${leftPct}%`, - width: `${widthPct}%`, - backgroundColor: color + '33', - borderLeftColor: color, - }, - ]} - > - - {event.title || t('calendar.events.no_title', '(No title)')} - - {height > 32 && ( - - {minutesToTimeLabel(startMinutes, timeFormat)} - + title={event.title || t('calendar.events.no_title', '(No title)')} + timeLabel={endMinutes - startMinutes >= TIME_LINE_MIN_MINUTES + ? minutesToTimeLabel(startMinutes, timeFormat) + : null} + colors={eventBlockColors( + getEventColor(event, calendars), + isInactiveEvent(event, currentUserEmails), + c, )} - + ringColor={c.background} + continuesBefore={continuesBefore} + continuesAfter={continuesAfter} + onPress={() => onSelectEvent?.(event)} + style={{ top, height, left: `${leftPct}%`, width: `${widthPct}%` }} + /> ); }, )} @@ -389,14 +380,6 @@ function makeStyles(c: ThemePalette) { top: 0, bottom: 0, }, - allDayChip: { - height: ALL_DAY_CHIP_HEIGHT, - borderRadius: radius.xs, - borderLeftWidth: 2, - paddingHorizontal: 4, - justifyContent: 'center', - }, - allDayChipText: { fontSize: 10, color: c.text, fontWeight: '500' }, scroll: { flex: 1 }, scrollContent: {}, @@ -419,16 +402,6 @@ function makeStyles(c: ThemePalette) { borderBottomWidth: 1, borderBottomColor: c.borderLight, }, - eventBlock: { - position: 'absolute', - borderRadius: radius.xs, - borderLeftWidth: 2, - paddingHorizontal: 3, - paddingVertical: 2, - overflow: 'hidden', - }, - eventBlockTitle: { fontSize: 10, color: c.text, fontWeight: '600' }, - eventBlockTime: { fontSize: 9, color: c.textMuted, marginTop: 1 }, nowLine: { position: 'absolute', diff --git a/src/components/toast-swipe.ts b/src/components/toast-swipe.ts new file mode 100644 index 00000000..ff9e31f5 --- /dev/null +++ b/src/components/toast-swipe.ts @@ -0,0 +1,39 @@ +// Pure decisions behind the toast's swipe-to-dismiss. On the phone a toast has +// no close button; swiping it sideways dismisses it (repos/branding/APP.md). +// Like swipe-gesture.ts for list rows, this sits outside the component +// because the PanResponder is built once: everything it decides on arrives as +// an argument, so nothing can go stale. + +/** The subset of RN's PanResponder gesture state this logic needs. */ +export interface ToastSwipeGesture { + dx: number; + dy: number; + vx: number; +} + +const MIN_DX_TO_CLAIM = 6; +const DIRECTION_BIAS = 1.5; // dx must dominate dy by this factor +// A release past this share of the card's width dismisses it, capped so a +// wide screen does not ask for a long drag. +const DISMISS_FRACTION = 0.35; +const MAX_DISMISS_DISTANCE = 120; +// A short, fast flick dismisses too. +const FLING_DX = 30; +const FLING_VX = 0.5; + +/** Whether a move is a sideways drag the toast should take over. */ +export function shouldClaimToastSwipe(g: ToastSwipeGesture): boolean { + if (Math.abs(g.dx) < MIN_DX_TO_CLAIM) return false; + return Math.abs(g.dx) >= Math.abs(g.dy) * DIRECTION_BIAS; +} + +/** + * What releasing the drag does: 1 or -1 dismisses the toast towards that + * side, 0 springs it back. `width` is the card's width (0 before layout). + */ +export function toastSwipeRelease(g: ToastSwipeGesture, width: number): 1 | -1 | 0 { + const distance = width > 0 ? Math.min(MAX_DISMISS_DISTANCE, width * DISMISS_FRACTION) : MAX_DISMISS_DISTANCE; + if (g.dx >= distance || (g.dx > FLING_DX && g.vx >= FLING_VX)) return 1; + if (g.dx <= -distance || (g.dx < -FLING_DX && g.vx <= -FLING_VX)) return -1; + return 0; +} diff --git a/src/lib/__tests__/calendar-participants.test.ts b/src/lib/__tests__/calendar-participants.test.ts index a5819ec1..6573d9dd 100644 --- a/src/lib/__tests__/calendar-participants.test.ts +++ b/src/lib/__tests__/calendar-participants.test.ts @@ -7,6 +7,8 @@ import { getParticipantList, getStatusCounts, getUserParticipantId, + isDeclinedByUser, + isInactiveEvent, isOrganizer, seedAttendees, } from '../calendar-participants'; @@ -171,3 +173,31 @@ describe('getParticipantList dedupe, names and organizer status', () => { })).toBe(3); }); }); + +describe('isDeclinedByUser / isInactiveEvent', () => { + const declined: Partial = { + participants: { + org: { email: 'alice@example.com', roles: { owner: true } }, + dave: { calendarAddress: 'mailto:dave@example.com', roles: { attendee: true }, participationStatus: 'declined' }, + bob: { email: 'bob@example.com', roles: { attendee: true }, participationStatus: 'accepted' }, + }, + }; + + it('matches any of the user addresses, case-insensitively', () => { + expect(isDeclinedByUser(declined, ['Dave@Example.com'])).toBe(true); + expect(isDeclinedByUser(declined, ['alias@example.com', 'dave@example.com'])).toBe(true); + expect(isDeclinedByUser(declined, ['bob@example.com'])).toBe(false); + }); + + it('is false without addresses or participants', () => { + expect(isDeclinedByUser(declined, [])).toBe(false); + expect(isDeclinedByUser(declined, undefined)).toBe(false); + expect(isDeclinedByUser({}, ['dave@example.com'])).toBe(false); + }); + + it('counts cancelled events as inactive too', () => { + expect(isInactiveEvent({ status: 'cancelled' }, undefined)).toBe(true); + expect(isInactiveEvent(declined, ['dave@example.com'])).toBe(true); + expect(isInactiveEvent({ ...declined, status: 'confirmed' }, ['bob@example.com'])).toBe(false); + }); +}); diff --git a/src/lib/__tests__/event-colors.test.ts b/src/lib/__tests__/event-colors.test.ts new file mode 100644 index 00000000..4ae6f07b --- /dev/null +++ b/src/lib/__tests__/event-colors.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from 'vitest'; +import { + DARK_EVENT_TEXT, + eventBlockColors, + inactiveEventTextColor, + mixColors, + parseColor, + readableTextOn, +} from '../event-colors'; + +describe('parseColor', () => { + it('reads the hex forms', () => { + expect(parseColor('#abc')).toEqual({ r: 170, g: 187, b: 204, a: 1 }); + expect(parseColor('#3B82F6')).toEqual({ r: 59, g: 130, b: 246, a: 1 }); + expect(parseColor('#3b82f680')).toEqual({ r: 59, g: 130, b: 246, a: 128 / 255 }); + expect(parseColor('#abcd')?.a).toBeCloseTo(0xdd / 255); + expect(parseColor(' #000000 ')).toEqual({ r: 0, g: 0, b: 0, a: 1 }); + }); + + it('reads rgb() and rgba() in both syntaxes', () => { + expect(parseColor('rgb(59, 130, 246)')).toEqual({ r: 59, g: 130, b: 246, a: 1 }); + expect(parseColor('rgba(59,130,246,0.5)')).toEqual({ r: 59, g: 130, b: 246, a: 0.5 }); + expect(parseColor('RGBA(128, 128, 128, .3)')).toEqual({ r: 128, g: 128, b: 128, a: 0.3 }); + expect(parseColor('rgb(59 130 246 / 50%)')).toEqual({ r: 59, g: 130, b: 246, a: 0.5 }); + expect(parseColor('rgb(100%, 0%, 0%)')).toEqual({ r: 255, g: 0, b: 0, a: 1 }); + }); + + it('gives null for what it does not understand', () => { + expect(parseColor('blue')).toBeNull(); + expect(parseColor('#12')).toBeNull(); + expect(parseColor('#12345')).toBeNull(); + expect(parseColor('rgb(1, 2)')).toBeNull(); + expect(parseColor('rgb(a, b, c)')).toBeNull(); + expect(parseColor('hsl(0, 100%, 50%)')).toBeNull(); + expect(parseColor('')).toBeNull(); + expect(parseColor(undefined)).toBeNull(); + }); +}); + +describe('readableTextOn', () => { + it('keeps white while white reaches 3:1 on the fill', () => { + expect(readableTextOn('#3b82f6')).toBe('#ffffff'); // blue-500, 3.68:1 + expect(readableTextOn('#ef4444')).toBe('#ffffff'); // red-500, 3.76:1 + expect(readableTextOn('#000')).toBe('#ffffff'); + expect(readableTextOn('rgb(99, 102, 241)')).toBe('#ffffff'); + }); + + it('switches to near-black on light fills', () => { + expect(readableTextOn('#22c55e')).toBe(DARK_EVENT_TEXT); // green-500, 2.3:1 + expect(readableTextOn('#eab308')).toBe(DARK_EVENT_TEXT); + expect(readableTextOn('#fbbf24')).toBe(DARK_EVENT_TEXT); + expect(readableTextOn('#ffffff')).toBe(DARK_EVENT_TEXT); + }); + + it('does not depend on the theme', () => { + // Same fill, same answer: nothing here reads the palette. + expect(readableTextOn('#60a5fa')).toBe(readableTextOn('#60A5FA')); + }); + + it('falls back to white for colours it cannot read', () => { + expect(readableTextOn('tomato')).toBe('#ffffff'); + }); +}); + +describe('mixColors', () => { + it('mixes like color-mix(in srgb)', () => { + expect(mixColors('#ff0000', '#0000ff', 0.55)).toBe('#8c0073'); + expect(mixColors('#ffffff', '#000000', 0.5)).toBe('#808080'); + expect(mixColors('#123456', '#abcdef', 1)).toBe('#123456'); + expect(mixColors('#123456', '#abcdef', 0)).toBe('#abcdef'); + }); + + it('interpolates alpha premultiplied', () => { + expect(mixColors('rgba(255, 0, 0, 0.5)', '#0000ff', 0.5)).toBe('rgba(85, 0, 170, 0.75)'); + expect(mixColors('rgba(0, 0, 0, 0)', 'rgba(0, 0, 0, 0)', 0.5)).toBe('rgba(0, 0, 0, 0)'); + }); + + it('gives null when a colour cannot be read', () => { + expect(mixColors('blue', '#000000', 0.5)).toBeNull(); + expect(mixColors('#000000', 'nope', 0.5)).toBeNull(); + }); +}); + +describe('inactiveEventTextColor', () => { + it('is the calendar colour mixed 55% with the text colour', () => { + // 0.55 × #3b82f6 + 0.45 × #0f172a + expect(inactiveEventTextColor('#3b82f6', '#0f172a')).toBe('#27529a'); + // 0.55 × #3b82f6 + 0.45 × #fafafa + expect(inactiveEventTextColor('#3b82f6', '#fafafa')).toBe('#91b8f8'); + }); + + it('falls back to the text colour', () => { + expect(inactiveEventTextColor('teal', '#0f172a')).toBe('#0f172a'); + }); +}); + +describe('eventBlockColors', () => { + const light = { background: '#ffffff', text: '#0f172a' }; + const dark = { background: '#09090b', text: '#fafafa' }; + + it('paints active events solid with the computed label colour in either theme', () => { + const expected = { fill: '#3b82f6', text: '#ffffff', border: null }; + expect(eventBlockColors('#3b82f6', false, light)).toEqual(expected); + expect(eventBlockColors('#3b82f6', false, dark)).toEqual(expected); + expect(eventBlockColors('#22c55e', false, dark).text).toBe(DARK_EVENT_TEXT); + }); + + it('puts declined and cancelled events on the page ground inside a calendar-coloured border', () => { + expect(eventBlockColors('#3b82f6', true, light)).toEqual({ + fill: '#ffffff', + text: '#27529a', + border: '#3b82f6', + }); + expect(eventBlockColors('#3b82f6', true, dark).fill).toBe('#09090b'); + }); +}); diff --git a/src/lib/calendar-participants.ts b/src/lib/calendar-participants.ts index ec7842ab..00f132c3 100644 --- a/src/lib/calendar-participants.ts +++ b/src/lib/calendar-participants.ts @@ -127,6 +127,30 @@ export function getUserStatus( return null; } +/** + * True when the user declined this event. An occurrence carries its override's + * participants, so one declined instance of an accepted series counts + * (webmail #1110). + */ +export function isDeclinedByUser( + event: Partial, + userEmails: string[] | undefined, +): boolean { + if (!userEmails || userEmails.length === 0) return false; + return getUserStatus(event, userEmails) === 'declined'; +} + +/** + * Declined by the user or cancelled by the organizer: the calendar draws both + * the same way, as an outlined, struck-through event (repos/branding/APP.md). + */ +export function isInactiveEvent( + event: Partial, + userEmails: string[] | undefined, +): boolean { + return event.status === 'cancelled' || isDeclinedByUser(event, userEmails); +} + /** When the same address appears in two participant entries, a real RSVP on * either one beats a missing/"needs-action" one — the duplicate is always the * entry that never replied. Between two real replies the first-seen entry diff --git a/src/lib/event-colors.ts b/src/lib/event-colors.ts new file mode 100644 index 00000000..43553e3a --- /dev/null +++ b/src/lib/event-colors.ts @@ -0,0 +1,172 @@ +// Colours for calendar events, which every view draws as a solid block of +// the calendar colour (repos/branding/APP.md, "Calendar events"). The label +// colour is computed from the fill rather than taken from the palette: a +// fixed "inverse" text colour flips with the theme while the fill does not. +// React Native has no color-mix(), so the declined/cancelled label colour is +// mixed here too. + +export interface Rgba { + /** 0–255 */ + r: number; + g: number; + b: number; + /** 0–1 */ + a: number; +} + +/** Label colour on fills too light for white. */ +export const DARK_EVENT_TEXT = '#111827'; +const WHITE = '#ffffff'; + +/** Share of the calendar colour in a declined or cancelled event's label. */ +export const INACTIVE_TEXT_MIX = 0.55; + +const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n)); + +function hexByte(hex: string): number { + return parseInt(hex.length === 1 ? hex + hex : hex, 16); +} + +// One rgb() channel: 0–255, or a percentage of 255. +function channel(token: string): number | null { + const pct = token.endsWith('%'); + const n = Number(pct ? token.slice(0, -1) : token); + if (!Number.isFinite(n)) return null; + return clamp(pct ? (n / 100) * 255 : n, 0, 255); +} + +// The alpha of rgba(): 0–1, or a percentage. +function alpha(token: string): number | null { + const pct = token.endsWith('%'); + const n = Number(pct ? token.slice(0, -1) : token); + if (!Number.isFinite(n)) return null; + return clamp(pct ? n / 100 : n, 0, 1); +} + +/** + * Parses `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, and `rgb()` / `rgba()` in + * both the comma and the space-separated syntax. Anything else (named + * colours, hsl()) gives null. + */ +export function parseColor(input: string | null | undefined): Rgba | null { + if (!input) return null; + const s = input.trim().toLowerCase(); + + const hex = /^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/.exec(s); + if (hex) { + const h = hex[1]; + const short = h.length <= 4; + const parts = short ? h.split('') : h.match(/../g) ?? []; + const [r, g, b, a] = parts.map(hexByte); + return { r, g, b, a: a === undefined ? 1 : a / 255 }; + } + + const fn = /^rgba?\(([^)]*)\)$/.exec(s); + if (fn) { + const body = fn[1].trim(); + let tokens: string[]; + let alphaToken: string | undefined; + if (body.includes(',')) { + tokens = body.split(',').map((t) => t.trim()); + if (tokens.length === 4) alphaToken = tokens.pop(); + } else { + const [rgb, a] = body.split('/').map((t) => t.trim()); + tokens = rgb ? rgb.split(/\s+/) : []; + alphaToken = a; + } + if (tokens.length !== 3) return null; + const [r, g, b] = tokens.map(channel); + const a = alphaToken === undefined ? 1 : alpha(alphaToken); + if (r === null || g === null || b === null || a === null) return null; + return { r, g, b, a }; + } + + return null; +} + +/** WCAG relative luminance of an sRGB colour (alpha ignored). */ +export function relativeLuminance({ r, g, b }: Rgba): number { + const lin = (v: number) => { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); +} + +/** + * Label colour for text on a solid event fill: white while white keeps 3:1 + * contrast with the fill, `#111827` on lighter fills. A colour that cannot be + * parsed gets white, like the webmail's readableTextOn(). + */ +export function readableTextOn(fill: string): string { + const rgb = parseColor(fill); + if (!rgb) return WHITE; + const contrastWithWhite = 1.05 / (relativeLuminance(rgb) + 0.05); + return contrastWithWhite >= 3 ? WHITE : DARK_EVENT_TEXT; +} + +function toHexByte(n: number): string { + return Math.round(clamp(n, 0, 255)).toString(16).padStart(2, '0'); +} + +/** `#rrggbb` for an opaque colour, `rgba(…)` otherwise. */ +export function formatColor({ r, g, b, a }: Rgba): string { + if (a >= 1) return `#${toHexByte(r)}${toHexByte(g)}${toHexByte(b)}`; + const round = (n: number) => Math.round(clamp(n, 0, 255)); + return `rgba(${round(r)}, ${round(g)}, ${round(b)}, ${Number(a.toFixed(3))})`; +} + +/** + * CSS `color-mix(in srgb, a , b)`: `weight` (0–1) of `a`, the rest + * of `b`, with premultiplied alpha as CSS does. Null when either colour + * cannot be parsed. + */ +export function mixColors(a: string, b: string, weight: number): string | null { + const ca = parseColor(a); + const cb = parseColor(b); + if (!ca || !cb) return null; + const wa = clamp(weight, 0, 1); + const wb = 1 - wa; + const outA = ca.a * wa + cb.a * wb; + if (outA === 0) return formatColor({ r: 0, g: 0, b: 0, a: 0 }); + const mix = (x: number, y: number) => (x * ca.a * wa + y * cb.a * wb) / outA; + return formatColor({ r: mix(ca.r, cb.r), g: mix(ca.g, cb.g), b: mix(ca.b, cb.b), a: outA }); +} + +/** + * Label colour of a declined or cancelled event: the calendar colour mixed + * 55% with the text colour. Falls back to the text colour when the calendar + * colour cannot be parsed. + */ +export function inactiveEventTextColor(calendarColor: string, textColor: string): string { + return mixColors(calendarColor, textColor, INACTIVE_TEXT_MIX) ?? textColor; +} + +export interface EventBlockColors { + /** Block background. */ + fill: string; + /** Title and time. */ + text: string; + /** Inset border in the calendar colour, for declined and cancelled events only. */ + border: string | null; +} + +/** + * Fill, label and border of an event block, bar or chip. Active events are + * solid calendar colour with a computed label colour; declined and cancelled + * ones sit on the page ground inside a calendar-coloured border. + */ +export function eventBlockColors( + calendarColor: string, + inactive: boolean, + page: { background: string; text: string }, +): EventBlockColors { + if (inactive) { + return { + fill: page.background, + text: inactiveEventTextColor(calendarColor, page.text), + border: calendarColor, + }; + } + return { fill: calendarColor, text: readableTextOn(calendarColor), border: null }; +} diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index aca6e297..3b7bb051 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -1050,6 +1050,7 @@ export default function CalendarScreen() { showWeekNumbers={calendarShowWeekNumbers} showTimeInMonthView={calendarShowTimeInMonth} timeFormat={calendarTimeFormat} + currentUserEmails={currentUserEmails} onSelectDate={handleSelectDate} onLongPressDate={openCreate} /> @@ -1065,6 +1066,7 @@ export default function CalendarScreen() { showWeekNumbers={calendarShowWeekNumbers} showTimeInMonthView={calendarShowTimeInMonth} timeFormat={calendarTimeFormat} + currentUserEmails={currentUserEmails} onSelectDate={handleSelectDate} onLongPressDate={openCreate} /> @@ -1084,6 +1086,7 @@ export default function CalendarScreen() { calendars={calendars} weekStartsOn={calendarFirstDayOfWeek} timeFormat={calendarTimeFormat} + currentUserEmails={currentUserEmails} onSelectDate={handleSelectDate} onSelectEvent={handleSelectEvent} onCreateAtTime={openCreate} @@ -1098,6 +1101,7 @@ export default function CalendarScreen() { calendars={calendars} weekStartsOn={calendarFirstDayOfWeek} timeFormat={calendarTimeFormat} + currentUserEmails={currentUserEmails} onSelectDate={handleSelectDate} onSelectEvent={handleSelectEvent} onCreateAtTime={openCreate} @@ -1118,6 +1122,7 @@ export default function CalendarScreen() { eventsByDay={eventsByDay} calendars={calendars} timeFormat={calendarTimeFormat} + currentUserEmails={currentUserEmails} onSelectEvent={handleSelectEvent} /> )} @@ -1137,6 +1142,7 @@ export default function CalendarScreen() { eventsByDay={eventsByDay} calendars={calendars} timeFormat={calendarTimeFormat} + currentUserEmails={currentUserEmails} onSelectEvent={handleSelectEvent} refreshing={refreshing} onRefresh={onRefresh} @@ -1261,6 +1267,7 @@ function DayEventList({ eventsByDay, calendars, timeFormat, + currentUserEmails, onSelectEvent, refreshing, onRefresh, @@ -1269,6 +1276,7 @@ function DayEventList({ eventsByDay: EventDayIndex; calendars: Calendar[]; timeFormat?: TimeFormat; + currentUserEmails?: string[]; onSelectEvent?: (event: CalendarEvent) => void; refreshing: boolean; onRefresh: () => void; @@ -1307,6 +1315,7 @@ function DayEventList({ event={event} calendars={calendars} timeFormat={timeFormat} + currentUserEmails={currentUserEmails} onPress={onSelectEvent} /> ))}