diff --git a/package-lock.json b/package-lock.json index 6dda73e44a1..a1b2024818c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "validator": "^13.15.35", "vite": "^8.0.16", "vite-plugin-comlink": "^5.3.0", + "web-haptics": "^0.0.6", "write-excel-file": "^3.0.6" }, "devDependencies": { @@ -4436,9 +4437,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4455,9 +4453,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4474,9 +4469,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4493,9 +4485,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4512,9 +4501,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4531,9 +4517,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -13916,9 +13899,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13939,9 +13919,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13962,9 +13939,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -13985,9 +13959,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -22365,6 +22336,32 @@ "node": ">=0.10.0" } }, + "node_modules/web-haptics": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/web-haptics/-/web-haptics-0.0.6.tgz", + "integrity": "sha512-eCzcf1LDi20+Fr0x9V3OkX92k0gxEQXaHajmhXHitsnk6SxPeshv8TBtBRqxyst8HI1uf2FyFVE7QS3jo1gkrw==", + "license": "MIT", + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18", + "svelte": ">=4", + "vue": ">=3" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", diff --git a/package.json b/package.json index bf8de7b1d8e..09de9a982d3 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "validator": "^13.15.35", "vite": "^8.0.16", "vite-plugin-comlink": "^5.3.0", + "web-haptics": "^0.0.6", "write-excel-file": "^3.0.6" }, "devDependencies": { diff --git a/src/components/button/index.tsx b/src/components/button/index.tsx index 82bb8922a59..87ada994ae5 100644 --- a/src/components/button/index.tsx +++ b/src/components/button/index.tsx @@ -25,6 +25,8 @@ const Button: FC = (props) => { if (variant === 'small') className = 'body-small-semibold'; + if (props.pressed) className += ' is-pressed'; + const getBackgroundColor = () => { let result = ''; diff --git a/src/components/button/index.types.ts b/src/components/button/index.types.ts index 18f36dcc7f8..123b9e61490 100644 --- a/src/components/button/index.types.ts +++ b/src/components/button/index.types.ts @@ -25,6 +25,15 @@ export type ButtonPropsType = { */ disabled?: boolean; + /** + * Forces the pressed (`:active`) look via an `is-pressed` class. + * + * Use when the browser skips `:active` for a press — e.g. the first press + * right after focus moves (switching tabs). Pair with an `&.is-pressed` + * selector in `sx` for non-default variants. + */ + pressed?: boolean; + /** * Variant style of the button. */ diff --git a/src/components/subpage_navbar/index.tsx b/src/components/subpage_navbar/index.tsx new file mode 100644 index 00000000000..03e99805c3e --- /dev/null +++ b/src/components/subpage_navbar/index.tsx @@ -0,0 +1,107 @@ +import { Box, useTheme } from '@mui/material'; +import { useAtomValue } from 'jotai'; +import { navBarOptionsState } from '@states/app'; +import { IconArrowBack } from '@components/icons'; +import IconButton from '@components/icon_button'; +import Typography from '@components/typography'; +import { SubpageNavbarProps } from './index.types'; + +/** Navigation bar for subpages/overlays shown as their own screen (e.g. on mobile). */ +const SubpageNavbar = ({ + title, + secondaryTitle, + onBack, + backLabel, + trailing, +}: SubpageNavbarProps) => { + const theme = useTheme(); + + const navBarOptions = useAtomValue(navBarOptionsState); + const subtitle = secondaryTitle ?? navBarOptions.title; + + const ellipsis = { + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + overflow: 'hidden', + maxWidth: '100%', + } as const; + + return ( + + + + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + + {trailing ?? ( + + )} + + ); +}; + +export default SubpageNavbar; diff --git a/src/components/subpage_navbar/index.types.ts b/src/components/subpage_navbar/index.types.ts new file mode 100644 index 00000000000..2af11b00eb4 --- /dev/null +++ b/src/components/subpage_navbar/index.types.ts @@ -0,0 +1,10 @@ +import { ReactNode } from 'react'; + +export type SubpageNavbarProps = { + title: string; + // Falls back to the parent page's navbar title when omitted. + secondaryTitle?: string; + onBack: () => void; + backLabel: string; + trailing?: ReactNode; +}; diff --git a/src/components/tab_switcher/index.tsx b/src/components/tab_switcher/index.tsx new file mode 100644 index 00000000000..9d6647af1b2 --- /dev/null +++ b/src/components/tab_switcher/index.tsx @@ -0,0 +1,111 @@ +import { Box, ButtonBase } from '@mui/material'; +import { cloneElement } from 'react'; +import { TabSwitcherOption, TabSwitcherProps } from './index.types'; + +/** Reusable segmented tab control. */ +const TabSwitcher = ({ + options, + value, + onChange, + ariaLabel, + sx, +}: TabSwitcherProps) => { + const activeIndex = options.findIndex((option) => option.value === value); + const gap = 8; + + return ( + + + + {options.map((option: TabSwitcherOption) => { + const isActive = option.value === value; + + return ( + onChange(option.value)} + sx={{ + position: 'relative', + zIndex: 1, + flex: 1, + minWidth: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '8px', + padding: '4px 16px', + minHeight: '28px', + borderRadius: 'var(--radius-m)', + fontFamily: 'inherit', + fontSize: '15px', + lineHeight: '20px', + fontWeight: isActive ? 500 : 400, + color: isActive ? 'var(--accent-dark)' : 'var(--accent-400)', + transition: 'color 0.16s ease-out', + '&.Mui-disabled': { opacity: 0.5 }, + '& svg, & svg g, & svg g path': { + fill: isActive ? 'var(--accent-dark)' : 'var(--accent-400)', + transition: 'fill 0.16s ease-out', + }, + '&:focus-visible': { outline: 'var(--accent-main) auto 1px' }, + }} + > + {option.icon && ( + + {cloneElement(option.icon, { width: 20, height: 20 })} + + )} + + {option.label} + + + ); + })} + + ); +}; + +export default TabSwitcher; diff --git a/src/components/tab_switcher/index.types.ts b/src/components/tab_switcher/index.types.ts new file mode 100644 index 00000000000..28825cd1e07 --- /dev/null +++ b/src/components/tab_switcher/index.types.ts @@ -0,0 +1,17 @@ +import { ReactElement } from 'react'; +import { SxProps, Theme } from '@mui/material'; + +export type TabSwitcherOption = { + value: T; + label: string; + icon?: ReactElement<{ width?: number; height?: number; color?: string }>; + disabled?: boolean; +}; + +export type TabSwitcherProps = { + options: TabSwitcherOption[]; + value: T; + onChange: (value: T) => void; + ariaLabel?: string; + sx?: SxProps; +}; diff --git a/src/constants/table_encryption_map.ts b/src/constants/table_encryption_map.ts index 2ff0597d3e3..86e96516b3f 100644 --- a/src/constants/table_encryption_map.ts +++ b/src/constants/table_encryption_map.ts @@ -71,6 +71,7 @@ export const TABLE_ENCRYPTION_MAP = { lastname: 'public', backup_automatic: 'shared', theme_follow_os_enabled: 'shared', + haptics_enabled: 'shared', hour_credits_enabled: 'shared', group_publishers_sort: 'shared', data_view: 'shared', diff --git a/src/definition/settings.ts b/src/definition/settings.ts index 0cb849da469..d57bf782ac0 100644 --- a/src/definition/settings.ts +++ b/src/definition/settings.ts @@ -207,6 +207,7 @@ export type SettingsType = { interval: { value: number; updatedAt: string }; }; theme_follow_os_enabled: { value: boolean; updatedAt: string }; + haptics_enabled: { value: boolean; updatedAt: string }; hour_credits_enabled: { value: boolean; updatedAt: string }; data_view: { value: string; updatedAt: string }; }; diff --git a/src/features/my_profile/app_settings/index.tsx b/src/features/my_profile/app_settings/index.tsx index fc54fb75997..19e2620809b 100644 --- a/src/features/my_profile/app_settings/index.tsx +++ b/src/features/my_profile/app_settings/index.tsx @@ -25,6 +25,9 @@ const AppSettings = () => { laptopUp, handleUpdateSyncTheme, syncTheme, + haptics, + handleUpdateHaptics, + showHaptics, } = useAppSettings(); return ( @@ -73,48 +76,85 @@ const AppSettings = () => { - - handleUpdateSyncTheme(e.target.checked)} - /> - - - {t('tr_autoThemeChange')} - - {t('tr_autoThemeChangeDesc')} - + {/* One group: the container draws a divider between direct children. */} + + + handleUpdateSyncTheme(e.target.checked)} + /> + + + {t('tr_autoThemeChange')} + + {t('tr_autoThemeChangeDesc')} + + + {!syncTheme && ( + + )} - {!syncTheme && ( - - )} - - + + + + )} + {t('tr_colorScheme')} diff --git a/src/features/my_profile/app_settings/useAppSettings.tsx b/src/features/my_profile/app_settings/useAppSettings.tsx index f39487815f7..e69034a3ca6 100644 --- a/src/features/my_profile/app_settings/useAppSettings.tsx +++ b/src/features/my_profile/app_settings/useAppSettings.tsx @@ -4,20 +4,25 @@ import { dbAppSettingsUpdate } from '@services/dexie/settings'; import { backupAutoState, backupIntervalState, + hapticsEnabledState, themeFollowOSEnabledState, } from '@states/settings'; -import { useBreakpoints } from '@hooks/index'; +import { useBreakpoints, useIsTouchDevice } from '@hooks/index'; const useAppSettings = () => { const { laptopUp } = useBreakpoints(); + const showHaptics = useIsTouchDevice(); + const autoBackup = useAtomValue(backupAutoState); const autoBackupInterval = useAtomValue(backupIntervalState); const followOSTheme = useAtomValue(themeFollowOSEnabledState); + const hapticsOn = useAtomValue(hapticsEnabledState); const [autoSync, setAutoSync] = useState(autoBackup); const [autoSyncInterval, setAutoSyncInterval] = useState(autoBackupInterval); const [syncTheme, setSyncTheme] = useState(followOSTheme); + const [haptics, setHaptics] = useState(hapticsOn); const handleSwitchAutoBackup = async (value) => { setAutoSync(value); @@ -52,10 +57,25 @@ const useAppSettings = () => { }); }; + const handleUpdateHaptics = async (value: boolean) => { + setHaptics(value); + + await dbAppSettingsUpdate({ + 'user_settings.haptics_enabled': { + value, + updatedAt: new Date().toISOString(), + }, + }); + }; + useEffect(() => { setSyncTheme(followOSTheme); }, [followOSTheme]); + useEffect(() => { + setHaptics(hapticsOn); + }, [hapticsOn]); + return { autoSync, handleSwitchAutoBackup, @@ -64,6 +84,9 @@ const useAppSettings = () => { laptopUp, syncTheme, handleUpdateSyncTheme, + haptics, + handleUpdateHaptics, + showHaptics, }; }; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/animated_count/index.tsx b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/animated_count/index.tsx new file mode 100644 index 00000000000..ab37d8d1522 --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/animated_count/index.tsx @@ -0,0 +1,258 @@ +import { Box } from '@mui/material'; +import { visuallyHidden } from '@mui/utils'; +import { useEffect, useRef, useState } from 'react'; +import Typography from '@components/typography'; +import { haptic } from '@services/haptics'; +import Confetti from '../confetti'; +import { AnimatedCountProps } from './index.types'; + +const H = 96; +const ROLL = 300; +const TWEEN = 550; +const CAPACITY = 4; +const MILESTONE = 1914; + +// Descending digit ribbon: increments always roll in from the top. +const COPIES = 3; +const TOTAL_CELLS = COPIES * 10; +const MID_BASE = 10; +const cellDigit = (i: number) => (10 - (i % 10)) % 10; +const CELLS = Array.from({ length: TOTAL_CELLS }, (_, i) => ({ + id: `cell-${i}`, + digit: cellDigit(i), +})); + +type Direction = 'up' | 'down'; + +const numberColor = (n: number) => + n > 0 ? 'var(--black)' : 'var(--accent-350)'; + +const baseIndex = (digit: number) => MID_BASE + ((10 - digit) % 10); + +type DigitColumnProps = { + digit: number; + dir: Direction; + collapsed: boolean; + color: string; + spaceAfter?: boolean; +}; + +const DigitColumn = ({ digit, dir, collapsed, color, spaceAfter }: DigitColumnProps) => { + const prevRef = useRef(digit); + const [{ pos, animate }, setState] = useState(() => ({ + pos: baseIndex(digit), + animate: false, + })); + const rafRef = useRef(undefined); + + useEffect(() => { + const prev = prevRef.current; + if (prev === digit) return; + prevRef.current = digit; + + const base = baseIndex(prev); + const forward = (digit - prev + 10) % 10; + const backward = (prev - digit + 10) % 10; + const target = dir === 'up' ? base - forward : base + backward; + + // Jump to the old digit without transition, then roll on the next frame. + setState({ pos: base, animate: false }); + if (rafRef.current) cancelAnimationFrame(rafRef.current); + rafRef.current = requestAnimationFrame(() => { + rafRef.current = requestAnimationFrame(() => + setState({ pos: target, animate: true }) + ); + }); + + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, [digit, dir]); + + return ( + + + {CELLS.map((cell) => ( + + {cell.digit} + + ))} + + + ); +}; + +const AnimatedCount = ({ value, label, shake = 0 }: AnimatedCountProps) => { + const safe = Math.max(0, value); + + const [display, setDisplay] = useState(safe); + const displayRef = useRef(safe); + const rafRef = useRef(undefined); + + const prevDisplayRef = useRef(safe); + + useEffect(() => { + const target = safe; + const from = displayRef.current; + + if (from === target) return; + + if (rafRef.current) cancelAnimationFrame(rafRef.current); + + if (Math.abs(target - from) <= 1) { + displayRef.current = target; + setDisplay(target); + return; + } + + let startTs: number | null = null; + const step = (ts: number) => { + startTs ??= ts; + const progress = Math.min((ts - startTs) / TWEEN, 1); + const eased = 1 - Math.pow(1 - progress, 3); + const current = Math.round(from + (target - from) * eased); + + displayRef.current = current; + setDisplay(current); + + if (progress < 1) { + rafRef.current = requestAnimationFrame(step); + } else { + displayRef.current = target; + setDisplay(target); + } + }; + + rafRef.current = requestAnimationFrame(step); + + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, [safe]); + + const dir: Direction = display < prevDisplayRef.current ? 'down' : 'up'; + useEffect(() => { + prevDisplayRef.current = display; + }, [display]); + + // Replays on each shake bump (not mount); WAAPI restarts cleanly every call. + const shakeRef = useRef(null); + const prevShakeRef = useRef(shake); + useEffect(() => { + if (shake === prevShakeRef.current) return; + prevShakeRef.current = shake; + shakeRef.current?.animate( + [ + { transform: 'translateX(0)' }, + { transform: 'translateX(-6px)' }, + { transform: 'translateX(5px)' }, + { transform: 'translateX(-4px)' }, + { transform: 'translateX(3px)' }, + { transform: 'translateX(0)' }, + ], + { duration: 400, easing: 'ease' } + ); + }, [shake]); + + const [confettiKey, setConfettiKey] = useState(0); + const wasMilestoneRef = useRef(safe === MILESTONE); + useEffect(() => { + const isMilestone = display === MILESTONE; + if (isMilestone && !wasMilestoneRef.current) { + setConfettiKey((prev) => prev + 1); + haptic('celebrate'); + } + wasMilestoneRef.current = isMilestone; + }, [display]); + + const milestone = display === MILESTONE; + const color = milestone ? 'var(--accent-main)' : numberColor(display); + const places = Array.from({ length: CAPACITY }, (_, i) => CAPACITY - 1 - i); + + return ( + + + {label} + + + + {display} + + + + + {places.map((place) => { + const digit = Math.floor(display / 10 ** place) % 10; + const collapsed = place > 0 && display < 10 ** place; + + return ( + + ); + })} + + + + {confettiKey > 0 && } + + ); +}; + +export default AnimatedCount; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/animated_count/index.types.ts b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/animated_count/index.types.ts new file mode 100644 index 00000000000..2683d2f93b6 --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/animated_count/index.types.ts @@ -0,0 +1,5 @@ +export type AnimatedCountProps = { + value: number; + label: string; + shake?: number; // bump to trigger a denial shake +}; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/confetti/index.tsx b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/confetti/index.tsx new file mode 100644 index 00000000000..bf5cb00501a --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/confetti/index.tsx @@ -0,0 +1,85 @@ +import { Box } from '@mui/material'; + +const COLORS = [ + 'var(--accent-main)', + 'var(--orange-main)', + 'var(--green-main)', + 'var(--red-main)', + 'var(--accent-dark)', +]; + +const PIECES = [ + { left: 6, sway: 30, fall: 900, rot: 300, dur: 2.4, delay: 0, size: 9, ci: 0 }, + { left: 14, sway: -24, fall: 860, rot: -260, dur: 2.7, delay: 0.15, size: 7, ci: 1 }, + { left: 23, sway: 36, fall: 920, rot: 220, dur: 2.3, delay: 0.05, size: 10, ci: 2 }, + { left: 32, sway: -28, fall: 880, rot: -320, dur: 2.9, delay: 0.22, size: 8, ci: 3 }, + { left: 40, sway: 22, fall: 840, rot: 180, dur: 2.5, delay: 0.38, size: 6, ci: 4 }, + { left: 49, sway: -34, fall: 900, rot: 280, dur: 2.8, delay: 0.28, size: 8, ci: 0 }, + { left: 58, sway: 26, fall: 860, rot: -240, dur: 2.4, delay: 0.08, size: 7, ci: 1 }, + { left: 67, sway: -30, fall: 920, rot: 320, dur: 2.7, delay: 0.18, size: 10, ci: 2 }, + { left: 76, sway: 32, fall: 880, rot: -300, dur: 2.3, delay: 0.26, size: 9, ci: 3 }, + { left: 85, sway: -22, fall: 840, rot: 200, dur: 2.6, delay: 0.12, size: 7, ci: 4 }, + { left: 93, sway: 28, fall: 900, rot: -180, dur: 2.9, delay: 0.34, size: 8, ci: 0 }, +] as const; + +const Confetti = () => { + return ( + theme.zIndex.modal + 1, + '@keyframes confetti-fall': { + '0%': { transform: 'translate(0, 0) rotate(0deg)', opacity: 0 }, + '5%': { opacity: 1 }, + '25%': { + transform: + 'translate(var(--sway), calc(var(--fall) * 0.25)) rotate(calc(var(--rot) * 0.25))', + }, + '50%': { + transform: + 'translate(calc(var(--sway) * -0.6), calc(var(--fall) * 0.5)) rotate(calc(var(--rot) * 0.5))', + }, + '75%': { + transform: + 'translate(calc(var(--sway) * 0.8), calc(var(--fall) * 0.75)) rotate(calc(var(--rot) * 0.75))', + }, + '92%': { + transform: + 'translate(calc(var(--sway) * -0.2), calc(var(--fall) * 0.92)) rotate(calc(var(--rot) * 0.92))', + opacity: 1, + }, + '100%': { + transform: + 'translate(calc(var(--sway) * 0.2), var(--fall)) rotate(var(--rot))', + opacity: 0, + }, + }, + }} + > + {PIECES.map((piece) => ( + + ))} + + ); +}; + +export default Confetti; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/count_swap/index.tsx b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/count_swap/index.tsx new file mode 100644 index 00000000000..2fd07e26eb5 --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/count_swap/index.tsx @@ -0,0 +1,112 @@ +import { Box } from '@mui/material'; +import { useEffect, useState } from 'react'; +import AnimatedCount from '../animated_count'; +import { CountSwapProps } from './index.types'; + +const DURATION = 400; +const DISTANCE = 80; +const EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; + +const keyframes = { + '@keyframes clicker-count-enter-left': { + from: { opacity: 0, transform: `translateX(-${DISTANCE}px)` }, + to: { opacity: 1, transform: 'translateX(0)' }, + }, + '@keyframes clicker-count-enter-right': { + from: { opacity: 0, transform: `translateX(${DISTANCE}px)` }, + to: { opacity: 1, transform: 'translateX(0)' }, + }, + '@keyframes clicker-count-exit-left': { + from: { opacity: 1, transform: 'translateX(0)' }, + to: { opacity: 0, transform: `translateX(-${DISTANCE}px)` }, + }, + '@keyframes clicker-count-exit-right': { + from: { opacity: 1, transform: 'translateX(0)' }, + to: { opacity: 0, transform: `translateX(${DISTANCE}px)` }, + }, +}; + +const side = (tab: string) => (tab === 'online' ? 'right' : 'left'); + +type Model = { + tab: CountSwapProps['tab']; + value: number; + seq: number; + exiting: { seq: number; tab: CountSwapProps['tab']; value: number } | null; +}; + +const CountSwap = ({ tab, value, label, shake }: CountSwapProps) => { + const [model, setModel] = useState({ tab, value, seq: 0, exiting: null }); + + // Derived during render so old + new appear in one commit — no flicker. + if (model.tab !== tab) { + setModel((prev) => ({ + tab, + value, + seq: prev.seq + 1, + exiting: { seq: prev.seq + 1, tab: prev.tab, value: prev.value }, + })); + } else if (model.value !== value) { + setModel((prev) => ({ ...prev, value })); + } + + useEffect(() => { + if (!model.exiting) return; + const exitingSeq = model.exiting.seq; + const id = setTimeout(() => { + setModel((prev) => + prev.exiting?.seq === exitingSeq ? { ...prev, exiting: null } : prev + ); + }, DURATION); + + return () => clearTimeout(id); + }, [model.exiting]); + + return ( + + 0 + ? `clicker-count-enter-${side(model.tab)} ${DURATION}ms ${EASING}` + : 'none', + }} + > + + + + {model.exiting && ( + + + + + + )} + + ); +}; + +export default CountSwap; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/count_swap/index.types.ts b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/count_swap/index.types.ts new file mode 100644 index 00000000000..61bf8618c01 --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/count_swap/index.types.ts @@ -0,0 +1,8 @@ +import { ClickerTab } from '../index.types'; + +export type CountSwapProps = { + tab: ClickerTab; + value: number; + label: string; + shake?: number; // bump to trigger a denial shake +}; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/counter_pad/index.tsx b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/counter_pad/index.tsx new file mode 100644 index 00000000000..a40027f15df --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/counter_pad/index.tsx @@ -0,0 +1,114 @@ +import { + MouseEvent as ReactMouseEvent, + PointerEvent as ReactPointerEvent, + useState, +} from 'react'; +import { Box, ButtonBase } from '@mui/material'; +import { IconAdd, IconRemove } from '@components/icons'; +import { useAppTranslation } from '@hooks/index'; +import { CounterPadProps } from './index.types'; + +// The pressed look lives in an `is-pressed` class we toggle from React, not in +// `:active`: the browser skips `:active` for the first press right after focus +// moves (e.g. switching tabs), which left that press styled as idle. +const pressedVisual = { + backgroundColor: 'var(--accent-main)', + transform: 'scale(0.96)', + '& svg, & svg g, & svg g path': { fill: 'var(--always-white)' }, +}; + +const baseTile = { + flex: 1, + minWidth: 0, + height: '128px', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'var(--accent-200)', + userSelect: 'none', + touchAction: 'manipulation', + transition: + 'background-color 0.18s ease, transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1)', + '& svg': { width: '48px', height: '48px' }, + '& svg, & svg g, & svg g path': { + fill: 'var(--accent-main)', + transition: 'fill 0.18s ease', + }, + '&:hover:not(.Mui-disabled)': { + '@media (hover: hover)': { + backgroundColor: 'var(--accent-main)', + '& svg, & svg g, & svg g path': { fill: 'var(--always-white)' }, + }, + }, + '&.is-pressed:not(.Mui-disabled)': pressedVisual, + '&:active:not(.Mui-disabled)': pressedVisual, + '&.Mui-disabled': { opacity: 0.5 }, + '&:focus-visible': { + outline: 'var(--accent-main) auto 1px', + outlineOffset: '-2px', + }, +}; + +type PressedTile = 'decrement' | 'increment' | null; + +const CounterPad = ({ + onIncrement, + onDecrement, + decrementDisabled, +}: CounterPadProps) => { + const { t } = useAppTranslation(); + + const [pressed, setPressed] = useState(null); + + // Count on pointer-down: a click is eaten when focus has just moved, e.g. + // right after switching tabs. Keyboard sends a click with detail 0. The + // pressed class mirrors that so the first press is styled as clicked too. + const pressHandlers = (tile: Exclude, action: () => void) => ({ + onPointerDown: (event: ReactPointerEvent) => { + if (event.button !== 0) return; + setPressed(tile); + action(); + }, + onPointerUp: () => setPressed(null), + onPointerLeave: () => setPressed(null), + onPointerCancel: () => setPressed(null), + onClick: (event: ReactMouseEvent) => { + if (event.detail === 0) action(); + }, + }); + + return ( + + + + + + + + + + ); +}; + +export default CounterPad; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/counter_pad/index.types.ts b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/counter_pad/index.types.ts new file mode 100644 index 00000000000..7c213917536 --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/counter_pad/index.types.ts @@ -0,0 +1,5 @@ +export type CounterPadProps = { + onIncrement: () => void; + onDecrement: () => void; + decrementDisabled?: boolean; +}; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/index.styles.ts b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/index.styles.ts new file mode 100644 index 00000000000..de0a45ab496 --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/index.styles.ts @@ -0,0 +1,21 @@ +import { Box } from '@mui/material'; +import { styled } from '@mui/material/styles'; + +export const ClickerLayout = styled(Box)({ + height: '100%', + display: 'flex', + flexDirection: 'column', + backgroundColor: 'var(--accent-100)', +}) as unknown as typeof Box; + +export const ClickerBody = styled(Box)({ + flex: 1, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + gap: '16px', + padding: '16px', + maxWidth: '480px', + width: '100%', + margin: '0 auto', +}) as unknown as typeof Box; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/index.tsx b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/index.tsx new file mode 100644 index 00000000000..3c090b66acc --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/index.tsx @@ -0,0 +1,225 @@ +import { + forwardRef, + type PointerEvent as ReactPointerEvent, + ReactElement, + Ref, + useMemo, + useRef, + useState, +} from 'react'; +import { Box, Dialog, Slide, Stack } from '@mui/material'; +import { TransitionProps } from '@mui/material/transitions'; +import { IconConference, IconRefresh, IconVisitors } from '@components/icons'; +import { useAppTranslation } from '@hooks/index'; +import { ClickerModeProps, ClickerTab } from './index.types'; +import useClickerMode from './useClickerMode'; +import { ClickerBody, ClickerLayout } from './index.styles'; +import Button from '@components/button'; +import SubpageNavbar from '@components/subpage_navbar'; +import TabSwitcher from '@components/tab_switcher'; +import CountSwap from './count_swap'; +import CounterPad from './counter_pad'; + +const SWIPE_THRESHOLD = 48; + +const SlideUp = forwardRef(function SlideUp( + props: TransitionProps & { children: ReactElement }, + ref: Ref +) { + return ; +}); + +const ClickerMode = (props: ClickerModeProps) => { + const { t } = useAppTranslation(); + + const { + open, + onClose, + title, + initialTab, + recordOnline, + presentValue, + onlineValue, + } = props; + + const { + tab, + setTab, + count, + resetSpin, + resetting, + shakeSignal, + handleIncrement, + handleDecrement, + handleReset, + handleSave, + } = useClickerMode({ + open, + onClose, + initialTab, + presentValue, + onlineValue, + onSave: props.onSave, + }); + + const resetActive = count > 0; + const resetVisible = resetActive || resetting; + + // Mirror the reset button's :active look via a class, so the first press + // right after a tab switch is styled as clicked (the browser skips :active + // for that press because focus has just moved). + const [resetPressed, setResetPressed] = useState(false); + + const tabOptions = useMemo( + () => [ + { value: 'present' as ClickerTab, label: t('tr_present'), icon: }, + { value: 'online' as ClickerTab, label: t('tr_online'), icon: }, + ], + [t] + ); + + const swipeStart = useRef<{ x: number; y: number } | null>(null); + + const handleSwipeStart = (event: ReactPointerEvent) => { + if (event.button !== 0) return; + swipeStart.current = { x: event.clientX, y: event.clientY }; + }; + + const handleSwipeEnd = (event: ReactPointerEvent) => { + const start = swipeStart.current; + swipeStart.current = null; + + if (!recordOnline || !start) return; + + const dx = event.clientX - start.x; + const dy = event.clientY - start.y; + if (Math.abs(dx) < SWIPE_THRESHOLD || Math.abs(dx) <= Math.abs(dy)) return; + + const order = tabOptions.map((option) => option.value); + const next = order[order.indexOf(tab) + (dx < 0 ? 1 : -1)]; + if (next) setTab(next); + }; + + return ( + + + + + + {recordOnline && ( + + value={tab} + onChange={setTab} + ariaLabel={t('tr_clickerMode')} + options={tabOptions} + /> + )} + + + + + + + { + if (event.button === 0) setResetPressed(true); + }} + onPointerUp={() => setResetPressed(false)} + onPointerLeave={() => setResetPressed(false)} + onPointerCancel={() => setResetPressed(false)} + sx={{ + opacity: resetVisible ? 1 : 0, + pointerEvents: resetVisible ? 'auto' : 'none', + transition: 'opacity 0.25s ease', + }} + > + + + + + + + + + + + + + + + + ); +}; + +export default ClickerMode; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/index.types.ts b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/index.types.ts new file mode 100644 index 00000000000..6f70538d768 --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/index.types.ts @@ -0,0 +1,17 @@ +export type ClickerTab = 'present' | 'online'; + +export type ClickerSaveValues = { + present?: number; + online?: number; +}; + +export type ClickerModeProps = { + open: boolean; + onClose: () => void; + title: string; + initialTab: ClickerTab; + recordOnline: boolean; + presentValue: number; + onlineValue: number; + onSave: (values: ClickerSaveValues) => void; +}; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/suggestion_button/index.tsx b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/suggestion_button/index.tsx new file mode 100644 index 00000000000..b8123216d65 --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/suggestion_button/index.tsx @@ -0,0 +1,67 @@ +import { Box, ButtonBase } from '@mui/material'; +import { IconClickerMode } from '@components/icons'; +import { ClickerSuggestionProps } from './index.types'; + +const ClickerSuggestion = ({ open, onOpen, label }: ClickerSuggestionProps) => { + return ( + event.preventDefault()} + onPointerDown={(event) => event.preventDefault()} + onClick={onOpen} + sx={{ + position: 'absolute', + top: 'calc(100% + 2px)', + // Field-width, growing up to 30% for a longer word, then ellipsis. + left: '50%', + width: 'max-content', + minWidth: '100%', + maxWidth: '130%', + zIndex: 20, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + gap: '6px', + padding: '9px 16px', + borderRadius: 'var(--radius-l)', + backgroundColor: 'var(--accent-main)', + color: 'var(--always-white)', + boxShadow: '0px 4px 16px rgba(var(--accent-main-base), 0.4)', + fontFamily: 'inherit', + fontSize: '13px', + fontWeight: 600, + lineHeight: '18px', + opacity: open ? 1 : 0, + transform: open + ? 'translateX(-50%) translateY(0)' + : 'translateX(-50%) translateY(8px)', + pointerEvents: open ? 'auto' : 'none', + transition: + 'opacity 0.22s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)', + '& svg, & svg g, & svg g path': { fill: 'var(--always-white)' }, + '&:active': { transform: 'translateX(-50%) translateY(0) scale(0.98)' }, + '&:focus-visible': { outline: 'var(--always-white) auto 1px' }, + }} + > + + + + + {label} + + + ); +}; + +export default ClickerSuggestion; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/suggestion_button/index.types.ts b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/suggestion_button/index.types.ts new file mode 100644 index 00000000000..79e854ae784 --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/suggestion_button/index.types.ts @@ -0,0 +1,5 @@ +export type ClickerSuggestionProps = { + open: boolean; + onOpen: () => void; + label: string; +}; diff --git a/src/features/reports/meeting_attendance/monthly_record/clicker_mode/useClickerMode.tsx b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/useClickerMode.tsx new file mode 100644 index 00000000000..5aa2dcbbdf7 --- /dev/null +++ b/src/features/reports/meeting_attendance/monthly_record/clicker_mode/useClickerMode.tsx @@ -0,0 +1,117 @@ +import { useEffect, useRef, useState } from 'react'; +import { ClickerModeProps, ClickerSaveValues, ClickerTab } from './index.types'; +import { haptic } from '@services/haptics'; + +type TabState = Record; + +const MAX_COUNT = 1914; + +// Matches the reset-arrow spin transition. +const RESET_SPIN_MS = 500; + +const useClickerMode = ({ + open, + onClose, + initialTab, + presentValue, + onlineValue, + onSave, +}: Pick< + ClickerModeProps, + 'open' | 'onClose' | 'initialTab' | 'presentValue' | 'onlineValue' | 'onSave' +>) => { + const [tab, setTab] = useState(initialTab); + const [counts, setCounts] = useState>({ + present: presentValue, + online: onlineValue, + }); + const [touched, setTouched] = useState>({ + present: false, + online: false, + }); + const [resetSpin, setResetSpin] = useState(0); + const [resetting, setResetting] = useState(false); + const [shakeSignal, setShakeSignal] = useState(0); + + const wasOpen = useRef(false); + const resetTimer = useRef>(undefined); + + useEffect(() => { + if (open && !wasOpen.current) { + setTab(initialTab); + setCounts({ present: presentValue, online: onlineValue }); + setTouched({ present: false, online: false }); + setResetSpin(0); + setResetting(false); + setShakeSignal(0); + clearTimeout(resetTimer.current); + } + + wasOpen.current = open; + }, [open, initialTab, presentValue, onlineValue]); + + useEffect(() => () => clearTimeout(resetTimer.current), []); + + const count = counts[tab]; + + const markTouched = () => { + setTouched((prev) => (prev[tab] ? prev : { ...prev, [tab]: true })); + }; + + const handleIncrement = () => { + if (count >= MAX_COUNT) { + setShakeSignal((prev) => prev + 1); + haptic('limit'); + return; + } + + setCounts((prev) => ({ ...prev, [tab]: Math.min(MAX_COUNT, prev[tab] + 1) })); + markTouched(); + haptic('tap'); + }; + + const handleDecrement = () => { + if (count === 0) return; + + setCounts((prev) => ({ ...prev, [tab]: Math.max(0, prev[tab] - 1) })); + markTouched(); + haptic('tap'); + }; + + const handleReset = () => { + setCounts((prev) => ({ ...prev, [tab]: 0 })); + markTouched(); + setResetSpin((prev) => prev + 1); + haptic('reset'); + + setResetting(true); + clearTimeout(resetTimer.current); + resetTimer.current = setTimeout(() => setResetting(false), RESET_SPIN_MS); + }; + + const handleSave = () => { + const values: ClickerSaveValues = {}; + + if (touched.present) values.present = counts.present; + if (touched.online) values.online = counts.online; + + onSave(values); + onClose(); + }; + + return { + tab, + setTab, + count, + touched: touched[tab], + resetSpin, + resetting, + shakeSignal, + handleIncrement, + handleDecrement, + handleReset, + handleSave, + }; +}; + +export default useClickerMode; diff --git a/src/features/reports/meeting_attendance/monthly_record/week_box/index.styles.ts b/src/features/reports/meeting_attendance/monthly_record/week_box/index.styles.ts index 02db1ceb2fd..4a2205c6b32 100644 --- a/src/features/reports/meeting_attendance/monthly_record/week_box/index.styles.ts +++ b/src/features/reports/meeting_attendance/monthly_record/week_box/index.styles.ts @@ -5,10 +5,10 @@ export const TextFieldStyles: SxProps = { padding: '0! important', }, '.MuiInputBase-input': { - textAlign: 'center', + textAlign: 'left', }, '& input': { - padding: '10.5px 10px 10.5px 2px', + padding: '10.5px 14px', }, '& input::-webkit-outer-spin-button': { WebkitAppearance: 'none', diff --git a/src/features/reports/meeting_attendance/monthly_record/week_box/index.tsx b/src/features/reports/meeting_attendance/monthly_record/week_box/index.tsx index e7134f87f61..d4b827a8e64 100644 --- a/src/features/reports/meeting_attendance/monthly_record/week_box/index.tsx +++ b/src/features/reports/meeting_attendance/monthly_record/week_box/index.tsx @@ -7,6 +7,8 @@ import NowIndicator from './now_indicator'; import TextField from '@components/textfield'; import Typography from '@components/typography'; import { memo } from 'react'; +import ClickerMode from '../clicker_mode'; +import ClickerSuggestion from '../clicker_mode/suggestion_button'; const WeekBox = (props: WeekBoxProps) => { const { t } = useAppTranslation(); @@ -23,8 +25,20 @@ const WeekBox = (props: WeekBoxProps) => { isWeekend, box_label, noMeeting, + clickerEnabled, + clickerOpen, + clickerTitle, + focusedField, + handleFieldFocus, + handleFieldBlur, + handleClickerOpen, + handleClickerClose, + handleClickerSave, } = useWeekBox(props); + const suggestionOpen = (field: 'present' | 'online') => + !clickerOpen && focusedField === field; + return ( @@ -48,17 +62,38 @@ const WeekBox = (props: WeekBoxProps) => { )} - { + if ( + !event.currentTarget.contains( + event.relatedTarget as Node | null + ) + ) { + handleFieldBlur(); + } }} - sx={TextFieldStyles} - /> + > + handleFieldFocus('present')} + disabled={noMeeting} + slotProps={{ + htmlInput: { className: 'h4' }, + }} + sx={TextFieldStyles} + /> + {clickerEnabled && ( + + )} + {recordOnline && ( { : 'unset' } > - { + if ( + !event.currentTarget.contains( + event.relatedTarget as Node | null + ) + ) { + handleFieldBlur(); + } }} - sx={TextFieldStyles} - /> + > + handleFieldFocus('online')} + disabled={noMeeting} + slotProps={{ + htmlInput: { className: 'h4' }, + }} + sx={TextFieldStyles} + /> + {clickerEnabled && ( + + )} + {isCurrent && } )} @@ -112,6 +168,19 @@ const WeekBox = (props: WeekBoxProps) => { {!recordOnline && isCurrent && } + + {clickerEnabled && ( + + )} ); }; diff --git a/src/features/reports/meeting_attendance/monthly_record/week_box/useWeekBox.tsx b/src/features/reports/meeting_attendance/monthly_record/week_box/useWeekBox.tsx index 815f72cc753..3f57c22b29d 100644 --- a/src/features/reports/meeting_attendance/monthly_record/week_box/useWeekBox.tsx +++ b/src/features/reports/meeting_attendance/monthly_record/week_box/useWeekBox.tsx @@ -5,7 +5,15 @@ import { WEEK_TYPE_LANGUAGE_GROUPS, WEEK_TYPE_NO_MEETING, } from '@constants/index'; -import { useAppTranslation } from '@hooks/index'; +import { + useAppTranslation, + useBreakpoints, + useIsTouchDevice, +} from '@hooks/index'; +import { + ClickerSaveValues, + ClickerTab, +} from '../clicker_mode/index.types'; import { addWeeks, firstWeekMonth, @@ -23,7 +31,10 @@ import { attendanceOnlineRecordState, userDataViewState, } from '@states/settings'; -import { meetingAttendancePresentSave } from '@services/app/meeting_attendance'; +import { + meetingAttendanceCountsSave, + meetingAttendancePresentSave, +} from '@services/app/meeting_attendance'; import { monthShortNamesState } from '@states/app'; import { schedulesState } from '@states/schedules'; import { schedulesGetMeetingDate } from '@services/app/schedules'; @@ -31,12 +42,18 @@ import { schedulesGetMeetingDate } from '@services/app/schedules'; const useWeekBox = ({ month, index, type, view }: WeekBoxProps) => { const { t } = useAppTranslation(); + const isTouchDevice = useIsTouchDevice(); + const { laptopDown } = useBreakpoints(); + const attendances = useAtomValue(meetingAttendanceState); const dataView = useAtomValue(userDataViewState); const recordOnline = useAtomValue(attendanceOnlineRecordState); const months = useAtomValue(monthShortNamesState); const schedules = useAtomValue(schedulesState); + const [focusedField, setFocusedField] = useState(null); + const [clickerOpen, setClickerOpen] = useState(false); + const schedule = useMemo(() => { const weeks = schedules.filter((record) => record.weekOf.includes(month)); const week = weeks.at(index - 1); @@ -245,6 +262,50 @@ const useWeekBox = ({ month, index, type, view }: WeekBoxProps) => { }); }; + const clickerEnabled = (laptopDown || isTouchDevice) && !noMeeting; + + const clickerTitle = useMemo(() => { + const meetingLabel = + type === 'midweek' ? t('tr_midweekMeeting') : t('tr_weekendMeeting'); + + return `${box_label}: ${meetingLabel}`; + }, [box_label, type, t]); + + const handleFieldFocus = (field: ClickerTab) => setFocusedField(field); + + const handleFieldBlur = () => setFocusedField(null); + + const handleClickerOpen = () => setClickerOpen(true); + + const handleClickerClose = () => setClickerOpen(false); + + const handleClickerSave = (values: ClickerSaveValues) => { + const counts: { record: 'present' | 'online'; count: string }[] = []; + + if (values.present !== undefined) { + const value = values.present.toString(); + setPresent(value); + counts.push({ record: 'present', count: value }); + } + + if (values.online !== undefined) { + const value = values.online.toString(); + setOnline(value); + counts.push({ record: 'online', count: value }); + } + + if (counts.length === 0) return; + + // One atomic write; the debounced single-field save would drop a value. + meetingAttendanceCountsSave({ + index, + month, + type, + counts, + dataView: view || dataView, + }); + }; + return { isCurrent, handlePresentChange, @@ -257,6 +318,15 @@ const useWeekBox = ({ month, index, type, view }: WeekBoxProps) => { isWeekend, box_label, noMeeting, + clickerEnabled, + clickerOpen, + clickerTitle, + focusedField, + handleFieldFocus, + handleFieldBlur, + handleClickerOpen, + handleClickerClose, + handleClickerSave, }; }; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 301403acce8..4d4502aa6a3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,5 +1,6 @@ export { default as useAppTranslation } from './useAppTranslation'; export { default as useBreakpoints } from './useBreakpoints'; +export { default as useIsTouchDevice } from './useIsTouchDevice'; export { default as useFirebaseAuth } from './useFirebaseAuth'; export { default as useGlobal } from './useGlobal'; export { default as useInternetChecker } from './useInternetChecker'; diff --git a/src/hooks/useIsTouchDevice.tsx b/src/hooks/useIsTouchDevice.tsx new file mode 100644 index 00000000000..09a7fcba08a --- /dev/null +++ b/src/hooks/useIsTouchDevice.tsx @@ -0,0 +1,8 @@ +import { useMediaQuery } from '@mui/material'; + +// True on touch-first devices (input capability, not width — includes big iPads). +const useIsTouchDevice = () => { + return useMediaQuery('(hover: none) and (pointer: coarse)', { noSsr: true }); +}; + +export default useIsTouchDevice; diff --git a/src/locales/en/meetings.json b/src/locales/en/meetings.json index a525c42bf92..fd1580f85d5 100644 --- a/src/locales/en/meetings.json +++ b/src/locales/en/meetings.json @@ -92,6 +92,10 @@ "tr_average": "Average", "tr_online": "Online", "tr_present": "Present", + "tr_clickerMode": "Counter", + "tr_increase": "Increase", + "tr_decrease": "Decrease", + "tr_reset": "Reset", "tr_numberOfMeetings": "Number of meetings", "tr_today": "Now", "tr_totalAttendance": "Total attendance", diff --git a/src/locales/en/profile.json b/src/locales/en/profile.json index f1f916ba8ac..d66bd5ae54c 100644 --- a/src/locales/en/profile.json +++ b/src/locales/en/profile.json @@ -33,6 +33,8 @@ "tr_modeDark": "Dark", "tr_autoThemeChange": "Automatic app theme change", "tr_autoThemeChangeDesc": "Follow your device’s system setting when choosing between light and dark mode", + "tr_hapticFeedback": "Haptic feedback", + "tr_hapticFeedbackDesc": "Vibrate when you interact with the app, if your device and browser support it", "tr_colorScheme": "Color scheme", "tr_blue": "Blue", "tr_green": "Green", diff --git a/src/services/app/meeting_attendance.ts b/src/services/app/meeting_attendance.ts index af7412fcf80..b5dc61b82c2 100644 --- a/src/services/app/meeting_attendance.ts +++ b/src/services/app/meeting_attendance.ts @@ -11,26 +11,20 @@ import { dbMeetingAttendanceSave } from '@services/dexie/meeting_attendance'; import { displaySnackNotification } from '@services/states/app'; import { getMessageByCode, getTranslation } from '@services/i18n/translation'; -const handleUpdateRecord = ({ +// Cloned record + data-view row (created if missing); clone keeps writes off the atom. +const getWritableAttendance = ({ index, month, - record, type, - value, dataView, }: { - month: string; index: number; - record: 'present' | 'online'; - value: number; + month: string; type: MeetingType; dataView: string; }) => { const attendances = store.get(meetingAttendanceState); - - const dbAttendance = attendances.find( - (record) => record.month_date === month - ); + const dbAttendance = attendances.find((record) => record.month_date === month); let attendance: MeetingAttendanceType; @@ -47,15 +41,35 @@ const handleUpdateRecord = ({ let current = meetingRecord.find((record) => record.type === dataView); if (!current) { - meetingRecord.push({ - type: dataView, - online: undefined, - present: undefined, - updatedAt: '', - }); - current = meetingRecord.find((record) => record.type === dataView); + current = { type: dataView, online: undefined, present: undefined, updatedAt: '' }; + meetingRecord.push(current); } + return { attendance, current }; +}; + +const handleUpdateRecord = ({ + index, + month, + record, + type, + value, + dataView, +}: { + month: string; + index: number; + record: 'present' | 'online'; + value: number; + type: MeetingType; + dataView: string; +}) => { + const { attendance, current } = getWritableAttendance({ + index, + month, + type, + dataView, + }); + current[record] = value; current.updatedAt = new Date().toISOString(); @@ -101,3 +115,43 @@ const handlePresentSaveDb = async ({ }; export const meetingAttendancePresentSave = debounce(handlePresentSaveDb, 10); + +// Both counts in one atomic write; the debounced save would drop a value. +export const meetingAttendanceCountsSave = async ({ + index, + month, + type, + counts, + dataView, +}: { + index: number; + month: string; + type: MeetingType; + counts: { record: 'present' | 'online'; count: string }[]; + dataView: string; +}) => { + try { + const { attendance, current } = getWritableAttendance({ + index, + month, + type, + dataView, + }); + + for (const { record, count } of counts) { + current[record] = count.length === 0 ? undefined : +count; + } + + current.updatedAt = new Date().toISOString(); + + await dbMeetingAttendanceSave(attendance); + } catch (error) { + console.error(error); + + displaySnackNotification({ + header: getTranslation({ key: 'tr_errorTitle' }), + message: getMessageByCode(error.message), + severity: 'error', + }); + } +}; diff --git a/src/services/dexie/schema.ts b/src/services/dexie/schema.ts index c39bfd275b2..c7bf4414b4e 100644 --- a/src/services/dexie/schema.ts +++ b/src/services/dexie/schema.ts @@ -357,6 +357,7 @@ export const settingSchema: SettingsType = { firstname: { value: '', updatedAt: '' }, lastname: { value: '', updatedAt: '' }, theme_follow_os_enabled: { value: false, updatedAt: '' }, + haptics_enabled: { value: true, updatedAt: '' }, user_avatar: undefined, user_local_uid: '', user_members_delegate: [], diff --git a/src/services/haptics/index.ts b/src/services/haptics/index.ts new file mode 100644 index 00000000000..a8fb383bfd4 --- /dev/null +++ b/src/services/haptics/index.ts @@ -0,0 +1,19 @@ +import { WebHaptics } from 'web-haptics'; +import { store } from '@states/index'; +import { hapticsEnabledState } from '@states/settings'; +import { HAPTIC_PATTERNS, type HapticIntent } from './patterns'; + +export type { HapticIntent } from './patterns'; + +// Single instance: web-haptics appends a DOM element for its iOS fallback. +const engine = new WebHaptics(); + +export const haptic = (intent: HapticIntent) => { + try { + if (!store.get(hapticsEnabledState)) return; + + void engine.trigger(HAPTIC_PATTERNS[intent]); + } catch { + // never let a non-essential enhancement break the interaction + } +}; diff --git a/src/services/haptics/patterns.ts b/src/services/haptics/patterns.ts new file mode 100644 index 00000000000..db08eaa484e --- /dev/null +++ b/src/services/haptics/patterns.ts @@ -0,0 +1,38 @@ +import type { Vibration } from 'web-haptics'; + +export type HapticIntent = 'tap' | 'limit' | 'reset' | 'celebrate'; + +const TAP_INTENSITY = 0.55; + +export const HAPTIC_PATTERNS: Record = { + tap: [{ duration: 10, intensity: TAP_INTENSITY }], + + // Mirrors the 400ms cap-refused shake: four decaying jolts, one every 80ms. + limit: [ + { duration: 40, intensity: 0.9 }, + { delay: 40, duration: 40, intensity: 0.75 }, + { delay: 40, duration: 40, intensity: 0.6 }, + { delay: 40, duration: 40, intensity: 0.45 }, + ], + + // Decays as the count rolls down, with the firm pulse landing at ~420ms — + // while the number is still settling onto 0, not after it arrives. + reset: [ + { duration: 60, intensity: TAP_INTENSITY }, + { duration: 80, intensity: 0.42 }, + { duration: 90, intensity: 0.28 }, + { duration: 90, intensity: 0.16 }, + { duration: 60, intensity: 0.07 }, + { delay: 40, duration: 70, intensity: 1 }, + ], + + // Light "prrr" alongside the confetti burst. + celebrate: [ + { duration: 12, intensity: 0.35 }, + { delay: 28, duration: 12, intensity: 0.35 }, + { delay: 28, duration: 12, intensity: 0.32 }, + { delay: 28, duration: 12, intensity: 0.28 }, + { delay: 28, duration: 12, intensity: 0.24 }, + { delay: 28, duration: 12, intensity: 0.2 }, + ], +}; diff --git a/src/services/worker/backupUtils.ts b/src/services/worker/backupUtils.ts index 6e3cd1a65e1..aa4892d4d89 100644 --- a/src/services/worker/backupUtils.ts +++ b/src/services/worker/backupUtils.ts @@ -2073,6 +2073,7 @@ export const dbExportDataBackup = async (backupData: BackupDataType) => { backup_automatic: settings.user_settings.backup_automatic, theme_follow_os_enabled: settings.user_settings.theme_follow_os_enabled, + haptics_enabled: settings.user_settings.haptics_enabled, hour_credits_enabled: settings.user_settings.hour_credits_enabled, }; diff --git a/src/states/settings.ts b/src/states/settings.ts index 540fa4d6dd4..6d72837b229 100644 --- a/src/states/settings.ts +++ b/src/states/settings.ts @@ -603,6 +603,12 @@ export const themeFollowOSEnabledState = atom((get) => { return settings.user_settings.theme_follow_os_enabled.value; }); +export const hapticsEnabledState = atom((get) => { + const settings = get(settingsState); + + return settings.user_settings.haptics_enabled?.value ?? true; +}); + export const hoursCreditsEnabledState = atom((get) => { const settings = get(settingsState);