Unify and refine viewer empty/loading/error states

Every viewer page (Timer, Countdown, Backstage, Studio, Timeline,
ProjectInfo, Operator) re-implemented the same data-loader flow by hand,
each with a hard-coded, unlocalized error string and drifting empty-state
styling. This centralizes that flow and elevates the shared presentation.

- Add ViewDataBoundary: a single component that renders the shared
  loading, error and optional no-data states from a QueryStatus, so every
  view handles them the same obvious way. Refactor all seven view loaders
  through it.
- Localize the error state via new common.fetch_error /
  common.fetch_error_hint keys (en + de/es/fr/it/pt), replacing the
  duplicated English literal with a two-line "Something went wrong /
  Please refresh the page" message.
- Elevate the shared Empty component: legible viewer-secondary color
  (was 10% white), responsive title sizing, softened illustration,
  optional supporting subtitle line, and a reduced-motion-safe fade-in.
- Unify loading: Cuesheet and the rundown table now use the animated
  Loader like every other view instead of an untranslated "Loading..."
  page.
- Tidy up: EmptyTableBody uses Empty's text prop, drop Countdown's
  now-redundant size override, and remove a stray semicolon rendered in
  the ProjectInfo empty state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq5XLTSQhU9ckZmCSW2SEr
This commit is contained in:
Claude
2026-07-25 12:06:25 +00:00
parent 2a890cf2b3
commit c070389937
23 changed files with 156 additions and 111 deletions
@@ -1,20 +1,49 @@
@use '@/theme/viewerDefs' as *;
.emptyContainer {
width: 100%;
text-align: center;
color: $white-10;
color: $viewer-secondary-color;
@media (prefers-reduced-motion: no-preference) {
animation: empty-fade-in $viewer-transition-time ease both;
}
.empty {
display: block;
width: min(100%, 24rem);
width: min(100%, 16rem);
margin-inline: auto;
opacity: 0.8;
opacity: 0.5;
}
.text {
display: block;
margin-inline: auto;
margin-top: min(2vh, 16px);
font-weight: 600;
font-size: 2em;
font-size: $title-font-size;
max-width: min(100%, 600px);
}
.secondary {
display: block;
margin-inline: auto;
margin-top: 0.5rem;
font-weight: 400;
font-size: $base-font-size;
color: $viewer-label-color;
max-width: min(100%, 600px);
}
}
@keyframes empty-fade-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@@ -7,15 +7,17 @@ import style from './Empty.module.scss';
interface EmptyProps {
text?: string;
secondary?: string;
injectedStyles?: CSSProperties;
className?: string;
}
export default function Empty({ text, className, injectedStyles }: EmptyProps) {
export default function Empty({ text, secondary, className, injectedStyles }: EmptyProps) {
return (
<div className={cx([style.emptyContainer, className])} style={injectedStyles}>
<EmptyImage className={style.empty} />
{text && <span className={style.text}>{text}</span>}
{secondary && <span className={style.secondary}>{secondary}</span>}
</div>
);
}
@@ -6,13 +6,14 @@ import style from './EmptyPage.module.scss';
interface EmptyPageProps {
text?: string;
secondary?: string;
injectedStyles?: CSSProperties;
}
export default function EmptyPage({ text, injectedStyles }: EmptyPageProps) {
export default function EmptyPage({ text, secondary, injectedStyles }: EmptyPageProps) {
return (
<div className={style.page}>
<Empty text={text} injectedStyles={injectedStyles} />
<Empty text={text} secondary={secondary} injectedStyles={injectedStyles} />
</div>
);
}
@@ -15,9 +15,4 @@
gap: 1rem;
margin-top: 1em;
}
.text {
font-weight: 600;
font-size: 2em;
}
}
@@ -18,8 +18,7 @@ export default function EmptyTableBody({ handleAddNew }: EmptyTableBodyProps) {
<tbody className={style.emptyContainer}>
<tr>
<td colSpan={99} className={style.emptyCell}>
<Empty injectedStyles={{ marginTop: '5vh' }} />
<span className={style.text}>{text}</span>
<Empty text={text} injectedStyles={{ marginTop: '5vh' }} />
{handleAddNew && (
<div className={style.inline}>
<Button onClick={() => handleAddNew(SupportedEntry.Event)} variant='primary' size='large'>
+6 -11
View File
@@ -1,7 +1,6 @@
import { OntimeView, isOntimeEvent, isOntimeGroup } from 'ontime-types';
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import useFollowComponent from '../../common/hooks/useFollowComponent';
import { useSelectedEventId } from '../../common/hooks/useSocket';
@@ -10,7 +9,7 @@ import { cx } from '../../common/utils/styleUtils';
import { throttle } from '../../common/utils/throttle';
import { getDefaultFormat } from '../../common/utils/time';
import { isTouchDevice } from '../../externals';
import Loader from '../../views/common/loader/Loader';
import ViewDataBoundary from '../../views/common/view-data-boundary/ViewDataBoundary';
import CustomFieldEditModal from './custom-field-edit-modal/CustomFieldEditModal';
import FollowButton from './follow-button/FollowButton';
import OperatorEvent from './operator-event/OperatorEvent';
@@ -30,15 +29,11 @@ export default function OperatorLoader() {
useWindowTitle('Operator');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Operator {...data} />;
return (
<ViewDataBoundary status={status}>
<Operator {...data} />
</ViewDataBoundary>
);
}
function Operator({ rundown, rundownMetadata, customFields, settings }: OperatorData) {
@@ -1,10 +1,10 @@
import { memo, useEffect, useMemo } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { EntryActionsProvider } from '../../../common/context/EntryActionsContext';
import useCustomFields from '../../../common/hooks-query/useCustomFields';
import { useLoadedRundownSource } from '../../../common/hooks-query/useScopedRundown';
import { useEntryActions } from '../../../common/hooks/useEntryAction';
import Loader from '../../../views/common/loader/Loader';
import CuesheetDnd from '../../../views/cuesheet/cuesheet-dnd/CuesheetDnd';
import CuesheetTable from '../../../views/cuesheet/cuesheet-table/CuesheetTable';
import { useCuesheetPermissions } from '../../../views/cuesheet/useTablePermissions';
@@ -32,13 +32,15 @@ function RundownTable() {
const columns = useMemo(() => makeRundownColumns(customFields), [customFields]);
// TODO: adopt the shared ViewDataBoundary (views/common/view-data-boundary) once this
// table exposes a single query status instead of the ad-hoc isLoading check
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<EntryActionsProvider actions={actions}>
<CuesheetDnd columns={columns} tableRoot='editor'>
{isLoading ? (
<EmptyPage text='Loading...' />
<Loader />
) : (
<CuesheetTable columns={columns} source={source} cuesheetMode={editorMode} tableRoot='editor' />
)}
@@ -16,6 +16,8 @@ export const langDe: TranslationObject = {
'common.started_at': 'Gestartet am',
'common.time_now': 'Aktuelle Zeit',
'common.no_data': 'Keine Daten',
'common.fetch_error': 'Etwas ist schiefgelaufen',
'common.fetch_error_hint': 'Bitte aktualisieren Sie die Seite',
'countdown.ended': 'Veranstaltung endete um',
'countdown.running': 'Veranstaltung läuft',
'countdown.group_running': 'Veranstaltung in Gruppe läuft',
@@ -16,6 +16,8 @@ export const langEs: TranslationObject = {
'common.started_at': 'Iniciado en',
'common.time_now': 'Ahora',
'common.no_data': 'Sin datos',
'common.fetch_error': 'Algo salió mal',
'common.fetch_error_hint': 'Actualiza la página',
'countdown.ended': 'Evento finalizado a las',
'countdown.running': 'Evento en curso',
'countdown.group_running': 'Evento en grupo en curso',
@@ -16,6 +16,8 @@ export const langFr: TranslationObject = {
'common.started_at': 'Commencé à',
'common.time_now': 'Heure',
'common.no_data': 'Aucune donnée',
'common.fetch_error': 'Une erreur est survenue',
'common.fetch_error_hint': 'Veuillez actualiser la page',
'countdown.ended': 'Évènement terminé à',
'countdown.running': 'Évènement en cours',
'countdown.group_running': 'Évènement du groupe en cours',
@@ -16,6 +16,8 @@ export const langIt: TranslationObject = {
'common.started_at': 'Iniziato Alle',
'common.time_now': 'Ora attuale',
'common.no_data': 'Nessun dato disponibile',
'common.fetch_error': 'Qualcosa è andato storto',
'common.fetch_error_hint': 'Aggiorna la pagina',
'countdown.ended': 'Evento finito alle',
'countdown.running': 'Evento in corso',
'countdown.group_running': 'Evento nel gruppo in corso',
@@ -16,6 +16,8 @@ export const langPt: TranslationObject = {
'common.started_at': 'Iniciado em',
'common.time_now': 'Hora atual',
'common.no_data': 'Sem dados',
'common.fetch_error': 'Algo correu mal',
'common.fetch_error_hint': 'Atualize a página',
'countdown.ended': 'Evento encerrado às',
'countdown.running': 'Evento em andamento',
'countdown.group_running': 'Evento em grupo em andamento',
+6 -11
View File
@@ -6,7 +6,6 @@ import { useEffect, useMemo, useState } from 'react';
import ProgressBar from '../../common/components/progress-bar/ProgressBar';
import QRCode from '../../common/components/qr-code/QrCode';
import Empty from '../../common/components/state/Empty';
import EmptyPage from '../../common/components/state/EmptyPage';
import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -16,9 +15,9 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx, timerPlaceholderMin } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../common/loader/Loader';
import ScheduleExport from '../common/schedule/ScheduleExport';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import ViewDataBoundary from '../common/view-data-boundary/ViewDataBoundary';
import { getBackstageOptions, useBackstageOptions } from './backstage.options';
import { getCardData, getIsPendingStart, getShowProgressBar, isOvertime } from './backstage.utils';
import { BackstageData, useBackstageData } from './useBackstageData';
@@ -30,15 +29,11 @@ export default function BackstageLoader() {
useWindowTitle('Backstage');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Backstage {...data} />;
return (
<ViewDataBoundary status={status}>
<Backstage {...data} />
</ViewDataBoundary>
);
}
function Backstage({ events, customFields, projectData, isMirrored, settings }: BackstageData) {
@@ -0,0 +1,47 @@
import { QueryStatus } from '@tanstack/react-query';
import { PropsWithChildren } from 'react';
import EmptyPage from '../../../common/components/state/EmptyPage';
import { useTranslation } from '../../../translation/TranslationProvider';
import Loader from '../loader/Loader';
interface ViewDataBoundaryProps {
status: QueryStatus;
/** render a full-page "no data" state instead of the children */
isEmpty?: boolean;
/** overrides the default "no data" message when isEmpty is true */
emptyText?: string;
}
/**
* Standard gate for a viewer's data-loading flow.
* Renders the shared loading, error and (optional) empty states so every view
* handles them the same way. Children are only rendered once the data is ready.
*/
export default function ViewDataBoundary({
status,
isEmpty,
emptyText,
children,
}: PropsWithChildren<ViewDataBoundaryProps>) {
const { getLocalizedString } = useTranslation();
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return (
<EmptyPage
text={getLocalizedString('common.fetch_error')}
secondary={getLocalizedString('common.fetch_error_hint')}
/>
);
}
if (isEmpty) {
return <EmptyPage text={emptyText ?? getLocalizedString('common.no_data')} />;
}
return <>{children}</>;
}
@@ -86,17 +86,6 @@ $item-height: 3.5rem;
}
}
.empty-state__content {
max-width: none;
span {
max-width: none;
white-space: nowrap;
font-size: clamp(1.5rem, 4vw, 2.25rem);
line-height: 1.1;
}
}
.list-container {
display: flex;
flex-direction: column;
+10 -15
View File
@@ -14,7 +14,6 @@ import { IoAdd } from 'react-icons/io5';
import Button from '../../common/components/buttons/Button';
import Empty from '../../common/components/state/Empty';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useAutoTickingClock } from '../../common/hooks/useAutoTickingClock';
@@ -22,8 +21,8 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { ExtendedEntry } from '../../common/utils/rundownMetadata';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../common/loader/Loader';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import ViewDataBoundary from '../common/view-data-boundary/ViewDataBoundary';
import { getCountdownOptions, useCountdownOptions } from './countdown.options';
import { getOrderedSubscriptions, resolveSubscriptionTarget } from './countdown.utils';
import CountdownSelect from './CountdownSelect';
@@ -38,15 +37,11 @@ export default function CountdownLoader() {
useWindowTitle('Countdown');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Countdown {...data} />;
return (
<ViewDataBoundary status={status}>
<Countdown {...data} />
</ViewDataBoundary>
);
}
function Countdown({ customFields, rundownData, projectData, isMirrored, settings }: CountdownData) {
@@ -87,7 +82,7 @@ function Countdown({ customFields, rundownData, projectData, isMirrored, setting
{!hasEvents && (
<div className='empty-state'>
<Empty text={getLocalizedString('common.no_data')} className='empty-state__content' />
<Empty text={getLocalizedString('common.no_data')} />
</div>
)}
@@ -121,7 +116,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscriptions.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -137,7 +132,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (subscribedEvents.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.select_event')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.select_event')} />
<Button variant='primary' size='xlarge' onClick={goToEditMode}>
<IoAdd /> Add
</Button>
@@ -154,7 +149,7 @@ function CountdownContents({ candidates, rundownData, subscriptions, goToEditMod
if (eventsToShow.length === 0) {
return (
<div className='empty-state'>
<Empty text={getLocalizedString('countdown.all_have_finished')} className='empty-state__content' />
<Empty text={getLocalizedString('countdown.all_have_finished')} />
</div>
);
}
@@ -2,11 +2,11 @@ import { MaybeString, ProjectRundown } from 'ontime-types';
import { memo, use, useMemo } from 'react';
import Select from '../../common/components/select/Select';
import EmptyPage from '../../common/components/state/EmptyPage';
import { PresetContext } from '../../common/context/PresetContext';
import useCustomFields from '../../common/hooks-query/useCustomFields';
import type { RundownSource } from '../../common/hooks-query/useScopedRundown';
import { AppMode } from '../../ontimeConfig';
import Loader from '../common/loader/Loader';
import CuesheetDnd from './cuesheet-dnd/CuesheetDnd';
import { makeCuesheetColumns } from './cuesheet-table/cuesheet-table-elements/cuesheetColsFactory';
import CuesheetTable from './cuesheet-table/CuesheetTable';
@@ -41,12 +41,14 @@ function CuesheetTableWrapper({
[customFields, cuesheetMode, preset],
);
// TODO: adopt the shared ViewDataBoundary (views/common/view-data-boundary) once this
// table exposes a single query status instead of the ad-hoc isLoading check
const isLoading = !customFields || customFieldStatus === 'pending';
return (
<CuesheetDnd columns={columns}>
{isLoading ? (
<EmptyPage text='Loading...' />
<Loader />
) : (
<CuesheetTable
columns={columns}
@@ -11,7 +11,6 @@ import {
TableVirtuosoHandle,
} from 'react-virtuoso';
import EmptyPage from '../../../common/components/state/EmptyPage';
import EmptyTableBody from '../../../common/components/state/EmptyTableBody';
import { useEntryActionsContext } from '../../../common/context/EntryActionsContext';
import type { RundownSource } from '../../../common/hooks-query/useScopedRundown';
@@ -19,6 +18,7 @@ import type { ExtendedEntry } from '../../../common/utils/rundownMetadata';
import { usePersistedRundownOptions } from '../../../features/rundown/rundown.options';
import { useEventSelection } from '../../../features/rundown/useEventSelection';
import { AppMode } from '../../../ontimeConfig';
import Loader from '../../common/loader/Loader';
import { usePersistedCuesheetOptions } from '../cuesheet.options';
import { useCuesheetPermissions } from '../useTablePermissions';
import { CuesheetHeader, SortableCuesheetHeader } from './cuesheet-table-elements/CuesheetHeader';
@@ -231,7 +231,7 @@ export default function CuesheetTable({
const isLoading = !flatRundown || status === 'pending';
if (isLoading) {
return <EmptyPage text='Loading...' />;
return <Loader />;
}
return (
@@ -6,7 +6,7 @@ import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../common/loader/Loader';
import ViewDataBoundary from '../common/view-data-boundary/ViewDataBoundary';
import { ProjectInfoData, useProjectInfoData } from './useProjectInfoData';
import './ProjectInfo.scss';
@@ -16,15 +16,11 @@ export default function ProjectInfoLoader() {
useWindowTitle('Project info');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <ProjectInfo {...data} />;
return (
<ViewDataBoundary status={status}>
<ProjectInfo {...data} />
</ViewDataBoundary>
);
}
function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
@@ -41,7 +37,7 @@ function ProjectInfo({ projectData, isMirrored }: ProjectInfoData) {
return (
<>
<ViewParamsEditor target={OntimeView.ProjectInfo} viewOptions={[]} />
<EmptyPage text={getLocalizedString('common.no_data')} />;
<EmptyPage text={getLocalizedString('common.no_data')} />
</>
);
}
+6 -11
View File
@@ -1,13 +1,12 @@
import { OntimeView } from 'ontime-types';
import { useMemo } from 'react';
import EmptyPage from '../../common/components/state/EmptyPage';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils';
import { getDefaultFormat } from '../../common/utils/time';
import Loader from '../common/loader/Loader';
import ViewDataBoundary from '../common/view-data-boundary/ViewDataBoundary';
import { getStudioOptions, useStudioOptions } from './studio.options';
import StudioClock from './StudioClock';
import StudioTimers from './StudioTimers';
@@ -20,15 +19,11 @@ export default function StudioLoader() {
useWindowTitle('Studio Clock');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Studio {...data} />;
return (
<ViewDataBoundary status={status}>
<Studio {...data} />
</ViewDataBoundary>
);
}
function Studio({ customFields, projectData, isMirrored, settings, viewSettings }: StudioData) {
@@ -9,8 +9,8 @@ import { useSelectedEventId } from '../../common/hooks/useSocket';
import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../common/loader/Loader';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import ViewDataBoundary from '../common/view-data-boundary/ViewDataBoundary';
import Timeline from './Timeline';
import { getTimelineOptions, useTimelineOptions } from './timeline.options';
import { getUpcomingEvents, useScopedRundown } from './timeline.utils';
@@ -24,15 +24,11 @@ export default function TimelinePageLoader() {
useWindowTitle('Timeline');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <TimelinePage {...data} />;
return (
<ViewDataBoundary status={status}>
<TimelinePage {...data} />
</ViewDataBoundary>
);
}
function TimelinePage({ events, customFields, projectData, settings }: TimelineData) {
+6 -11
View File
@@ -3,7 +3,6 @@ import { useMemo } from 'react';
import { FitText } from '../../common/components/fit-text/FitText';
import MultiPartProgressBar from '../../common/components/multi-part-progress-bar/MultiPartProgressBar';
import EmptyPage from '../../common/components/state/EmptyPage';
import TitleCard from '../../common/components/title-card/TitleCard';
import ViewLogo from '../../common/components/view-logo/ViewLogo';
import ViewParamsEditor from '../../common/components/view-params-editor/ViewParamsEditor';
@@ -13,8 +12,8 @@ import { useWindowTitle } from '../../common/hooks/useWindowTitle';
import { cx } from '../../common/utils/styleUtils';
import { formatTime, getDefaultFormat } from '../../common/utils/time';
import { useTranslation } from '../../translation/TranslationProvider';
import Loader from '../common/loader/Loader';
import SuperscriptTime from '../common/superscript-time/SuperscriptTime';
import ViewDataBoundary from '../common/view-data-boundary/ViewDataBoundary';
import { getFormattedTimer, getTimerByType } from '../common/viewUtils';
import { getTimerColour } from '../utils/presentation.utils';
import { getTimerOptions, useTimerOptions } from './timer.options';
@@ -38,15 +37,11 @@ export default function TimerLoader() {
useWindowTitle('Timer');
if (status === 'pending') {
return <Loader />;
}
if (status === 'error') {
return <EmptyPage text='There was an error fetching data, please refresh the page.' />;
}
return <Timer {...data} />;
return (
<ViewDataBoundary status={status}>
<Timer {...data} />
</ViewDataBoundary>
);
}
function Timer({ customFields, projectData, isMirrored, settings, viewSettings, entries }: TimerData) {
+2
View File
@@ -14,6 +14,8 @@ export const langEn = {
'common.started_at': 'Started At',
'common.time_now': 'Time now',
'common.no_data': 'No data',
'common.fetch_error': 'Something went wrong',
'common.fetch_error_hint': 'Please refresh the page',
'countdown.ended': 'Event ended at',
'countdown.running': 'Event running',
'countdown.group_running': 'Event in group running',