feat: restyle calendar events and toasts to the app design

This commit is contained in:
Linus Rath
2026-09-27 12:34:58 +02:00
parent 8f604bf02e
commit dca9417aee
16 changed files with 1010 additions and 236 deletions
+227 -66
View File
@@ -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 (
<Animated.View
style={[cardStyles.card, { borderLeftColor: accent, opacity, transform: [{ translateY }] }]}
accessibilityRole="alert"
accessibilityLiveRegion="polite"
style={[cardStyles.shadow, { opacity: cardOpacity, transform: [{ translateY }, { translateX }] }]}
onLayout={onLayout}
{...responder.panHandlers}
>
<Icon size={18} color={accent} />
<View style={{ flex: 1 }}>
<Text style={cardStyles.title} numberOfLines={2}>{toast.title}</Text>
{toast.message ? <Text style={cardStyles.message} numberOfLines={3}>{toast.message}</Text> : null}
</View>
{toast.action ? (
<Pressable
onPress={() => { toast.action?.onPress(); removeToast(toast.id); }}
hitSlop={8}
accessibilityRole="button"
style={({ pressed }) => [cardStyles.actionBtn, pressed && cardStyles.actionBtnPressed]}
<View style={cardStyles.card} accessibilityRole="alert" accessibilityLiveRegion="polite">
<Icon size={18} color={iconColor} style={cardStyles.icon} />
<View
style={cardStyles.body}
accessible
accessibilityActions={dismissActions}
onAccessibilityAction={onAccessibilityAction}
>
<Text style={[cardStyles.actionText, { color: accent }]}>{toast.action.label}</Text>
</Pressable>
) : null}
<Pressable
onPress={() => removeToast(toast.id)}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={t('common.dismiss', 'Dismiss')}
style={cardStyles.close}
>
<X size={14} color={c.mutedForeground} />
</Pressable>
<Text style={cardStyles.title} numberOfLines={2}>{toast.title}</Text>
{toast.message ? <Text style={cardStyles.message} numberOfLines={3}>{toast.message}</Text> : null}
</View>
{toast.action ? (
<Pressable
onPress={() => { 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. */}
<View style={cardStyles.actionUnderline}>
<Text style={cardStyles.actionText} numberOfLines={1}>{toast.action.label}</Text>
</View>
</Pressable>
) : null}
<View pointerEvents="none" style={cardStyles.countdownTrack} />
<Animated.View
pointerEvents="none"
style={[
cardStyles.countdownFill,
{ backgroundColor: countdownColor, transform: [{ scaleX: remainingShare }] },
]}
/>
</View>
</Animated.View>
);
}
@@ -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',
},
});
}
@@ -11,6 +11,7 @@ const SRC = join(__dirname, '..', '..');
const TRANSLATED_FILES: Record<string, string[]> = {
'screens/CalendarScreen.tsx': [],
'components/calendar/AgendaView.tsx': [],
'components/calendar/EventBlock.tsx': [],
'components/calendar/EventCard.tsx': [],
'components/calendar/MonthScrollView.tsx': [],
'components/calendar/MonthView.tsx': [],
@@ -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);
});
});
+10 -1
View File
@@ -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 }) => (
<View style={styles.itemWrap}>
<EventCard event={item} calendars={calendars} timeFormat={timeFormat} onPress={onSelectEvent} />
<EventCard
event={item}
calendars={calendars}
timeFormat={timeFormat}
currentUserEmails={currentUserEmails}
onPress={onSelectEvent}
/>
</View>
)}
renderSectionFooter={({ section }) =>
+169
View File
@@ -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<ViewStyle>;
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 (
<Pressable
onPress={onPress}
style={[
styles.block,
corners('vertical', continuesBefore, continuesAfter, EVENT_RADIUS),
{ backgroundColor: colors.fill, borderColor: ringColor },
style,
]}
>
{inactive && (
// The calendar-coloured outline sits inside the ring.
<View
pointerEvents="none"
style={[
styles.outline,
corners('vertical', continuesBefore, continuesAfter, EVENT_RADIUS - 1),
{ borderColor: colors.border ?? undefined },
]}
/>
)}
<Text
style={[styles.blockTitle, { color: colors.text }, inactive && styles.struck]}
numberOfLines={1}
>
{title}
</Text>
{timeLabel !== null && (
<Text style={[styles.blockTime, { color: colors.text }]} numberOfLines={1}>
{timeLabel}
</Text>
)}
</Pressable>
);
}
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<ViewStyle>;
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 (
<Pressable
onPress={onPress}
style={[
styles.bar,
corners('horizontal', continuesBefore, continuesAfter, EVENT_RADIUS),
{ backgroundColor: colors.fill },
inactive && [styles.barInactive, { borderColor: colors.border ?? undefined }],
style,
]}
>
<Text style={[styles.barTitle, { color: colors.text }]} numberOfLines={1}>
{continuesBefore ? '… ' : ''}
<Text style={inactive && styles.struck}>{title}</Text>
</Text>
</Pressable>
);
}
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' },
});
+35 -17
View File
@@ -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 (
<Pressable
style={({ pressed }) => [styles.card, pressed && styles.cardPressed]}
style={({ pressed }) => [styles.row, pressed && styles.rowPressed]}
onPress={() => onPress?.(event)}
onLongPress={() => onLongPress?.(event)}
>
<View style={[styles.colorBar, { backgroundColor: color }]} />
<View style={[styles.dot, inactive ? [styles.dotHollow, { borderColor: color }] : { backgroundColor: color }]} />
<View style={styles.body}>
<View style={styles.headerRow}>
<Text style={[styles.title, cancelled && styles.titleCancelled]} numberOfLines={1}>
<Text style={[styles.title, inactive && styles.titleInactive]} numberOfLines={1}>
{event.title || t('calendar.events.no_title', '(No title)')}
</Text>
{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,
@@ -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,
+37 -13
View File
@@ -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 (
<View style={[styles.weekRow, height !== undefined && { height, overflow: 'hidden' }]}>
{showWeekNumbers && (
@@ -197,17 +207,24 @@ function MonthWeekRowInner({
{dayEvents.length > 0 && showTimeInMonthView && (
<View style={styles.chipsCol}>
{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 (
<View key={`${event.id}-${idx}`} style={[styles.chip, { backgroundColor: color }]}>
<Text
style={[styles.chipText, cancelled && styles.chipTextCancelled]}
numberOfLines={1}
>
<View
key={`${event.id}-${idx}`}
style={[
styles.chip,
{ backgroundColor: colors.fill },
colors.border !== null && [styles.chipInactive, { borderColor: colors.border }],
]}
>
<Text style={[styles.chipText, { color: colors.text }]} numberOfLines={1}>
{event.showWithoutTime
? (event.title || '')
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} ${event.title || ''}`}
? null
: `${format(getEventStartDate(event), timePattern(timeFormat), { locale })} `}
<Text style={inactive && styles.chipTextInactive}>{event.title || ''}</Text>
</Text>
</View>
);
@@ -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,
+53 -72
View File
@@ -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 (
<Pressable
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
onPress={() => 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,
},
]}
>
<Text style={styles.allDayChipText} numberOfLines={1}>
{segment.continuesBefore ? '… ' : ''}
{segment.event.title || t('calendar.events.no_title', '(No title)')}
</Text>
</Pressable>
);
})}
{visibleSegments.map((segment) => (
<AllDayEventBar
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
title={segment.event.title || t('calendar.events.no_title', '(No title)')}
colors={eventBlockColors(
getEventColor(segment.event, calendars),
isInactiveEvent(segment.event, currentUserEmails),
c,
)}
continuesBefore={segment.continuesBefore}
continuesAfter={segment.continuesAfter}
onPress={() => 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,
}}
/>
))}
</Animated.View>
</View>
</View>
@@ -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 (
<Pressable
<TimedEventBlock
key={event.id}
onPress={() => onSelectEvent?.(event)}
style={[
styles.eventBlock,
{
top,
height,
left: `${column * widthPct}%`,
width: `${widthPct}%`,
backgroundColor: color + '33',
borderLeftColor: color,
},
]}
>
<Text style={styles.eventBlockTitle} numberOfLines={1}>
{event.title || t('calendar.events.no_title', '(No title)')}
</Text>
{height > 32 && (
<Text style={styles.eventBlockTime} numberOfLines={1}>
{minutesToTimeLabel(startMinutes, timeFormat)}
</Text>
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,
)}
</Pressable>
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,
+40 -67
View File
@@ -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 */}
<View style={styles.allDayOverlay} pointerEvents="box-none">
{allDaySegments.map((segment) => {
const color = getEventColor(segment.event, calendars);
const leftPct = (segment.startIndex / weekDays.length) * 100;
const widthPct = (segment.span / weekDays.length) * 100;
return (
<Pressable
<AllDayEventBar
key={`${segment.event.id}:${segment.startIndex}:${segment.row}`}
title={segment.event.title || t('calendar.events.no_title', '(No title)')}
colors={eventBlockColors(
getEventColor(segment.event, calendars),
isInactiveEvent(segment.event, currentUserEmails),
c,
)}
continuesBefore={segment.continuesBefore}
continuesAfter={segment.continuesAfter}
onPress={() => 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,
},
]}
>
<Text style={styles.allDayChipText} numberOfLines={1}>
{segment.continuesBefore ? '… ' : ''}
{segment.event.title || t('calendar.events.no_title', '(No title)')}
</Text>
</Pressable>
style={{
left: `${leftPct}%`,
width: `${widthPct}%`,
top: segment.row * (ALL_DAY_CHIP_HEIGHT + ALL_DAY_GAP),
height: ALL_DAY_CHIP_HEIGHT,
marginHorizontal: 1,
}}
/>
);
})}
</View>
@@ -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 (
<Pressable
<TimedEventBlock
key={event.id}
onPress={() => onSelectEvent?.(event)}
style={[
styles.eventBlock,
{
top,
height,
left: `${leftPct}%`,
width: `${widthPct}%`,
backgroundColor: color + '33',
borderLeftColor: color,
},
]}
>
<Text style={styles.eventBlockTitle} numberOfLines={1}>
{event.title || t('calendar.events.no_title', '(No title)')}
</Text>
{height > 32 && (
<Text style={styles.eventBlockTime} numberOfLines={1}>
{minutesToTimeLabel(startMinutes, timeFormat)}
</Text>
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,
)}
</Pressable>
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',
+39
View File
@@ -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;
}
@@ -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<CalendarEvent> = {
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);
});
});
+116
View File
@@ -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');
});
});
+24
View File
@@ -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<CalendarEvent>,
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<CalendarEvent>,
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
+172
View File
@@ -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 <weight>, 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 };
}
+9
View File
@@ -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}
/>
))}