diff --git a/backend/src/db/api/publish_events.js b/backend/src/db/api/publish_events.js
index be57143..f01e4ea 100644
--- a/backend/src/db/api/publish_events.js
+++ b/backend/src/db/api/publish_events.js
@@ -28,6 +28,10 @@ class Publish_eventsDBApi extends GenericDBApi {
return ['from_environment', 'to_environment', 'status'];
}
+ static get UUID_FIELDS() {
+ return ['projectId'];
+ }
+
static get CSV_FIELDS() {
return [
'id',
diff --git a/frontend/src/components/BaseButton.tsx b/frontend/src/components/BaseButton.tsx
index d44ea02..427dec0 100644
--- a/frontend/src/components/BaseButton.tsx
+++ b/frontend/src/components/BaseButton.tsx
@@ -7,6 +7,8 @@ import { useAppSelector } from '../stores/hooks';
type Props = {
label?: string;
+ /** Optional subtitle displayed below the label in smaller text */
+ subtitle?: string;
icon?: string;
iconSize?: string | number;
href?: string;
@@ -26,6 +28,7 @@ type Props = {
export default function BaseButton({
label,
+ subtitle,
icon,
iconSize,
href,
@@ -78,9 +81,17 @@ export default function BaseButton({
{icon && (
)}
- {label && (
+ {label && !subtitle && (
{label}
)}
+ {label && subtitle && (
+
+ {label}
+ {subtitle}
+
+ )}
>
);
diff --git a/frontend/src/components/Constructor/ConstructorMenu.tsx b/frontend/src/components/Constructor/ConstructorMenu.tsx
index 12c7b5d..f093932 100644
--- a/frontend/src/components/Constructor/ConstructorMenu.tsx
+++ b/frontend/src/components/Constructor/ConstructorMenu.tsx
@@ -15,10 +15,10 @@ import {
mdiSwapHorizontal,
mdiText,
mdiPlus,
- mdiContentSave,
mdiExitToApp,
} from '@mdi/js';
import MenuActionButton from './MenuActionButton';
+import dataFormatter from '../../helpers/dataFormatter';
import type {
Position,
CanvasElementType,
@@ -41,6 +41,10 @@ interface ConstructorMenuProps {
onSave: () => void;
onSaveToStage: () => void;
onExit: () => void;
+ /** Page's last saved timestamp (updatedAt from tour_pages) */
+ lastSavedAt?: string | null;
+ /** Last save-to-stage timestamp */
+ lastSavedToStageAt?: string | null;
}
const ConstructorMenu = forwardRef(
@@ -60,6 +64,8 @@ const ConstructorMenu = forwardRef(
onSave,
onSaveToStage,
onExit,
+ lastSavedAt,
+ lastSavedToStageAt,
},
ref,
) => {
@@ -144,23 +150,32 @@ const ConstructorMenu = forwardRef(
/>
-
-
-
-
+
+
1 ? 's' : ''} ago`;
+ }
+
+ // Format as date + time
+ const isToday = d.isSame(now, 'day');
+ const isYesterday = d.isSame(now.subtract(1, 'day'), 'day');
+
+ const timeStr = d.format('HH:mm');
+
+ if (isToday) return `Today at ${timeStr}`;
+ if (isYesterday) return `Yesterday at ${timeStr}`;
+
+ return `${d.format('MMM D')} at ${timeStr}`;
+ },
};
export default dataFormatter;
diff --git a/frontend/src/hooks/usePublishStatus.ts b/frontend/src/hooks/usePublishStatus.ts
new file mode 100644
index 0000000..6518654
--- /dev/null
+++ b/frontend/src/hooks/usePublishStatus.ts
@@ -0,0 +1,112 @@
+/**
+ * usePublishStatus Hook
+ *
+ * Fetches the last publish/save events for a project to display timestamps.
+ * Follows useDashboardCounts pattern with mountedRef and robust error handling.
+ */
+import { useState, useEffect, useCallback, useRef } from 'react';
+import axios from 'axios';
+import { logger } from '../lib/logger';
+
+interface UsePublishStatusOptions {
+ /** Project ID to fetch status for */
+ projectId: string | null;
+}
+
+interface UsePublishStatusResult {
+ /** Last successful dev -> stage save timestamp */
+ lastSavedToStage: string | null;
+ /** Last successful stage -> production publish timestamp */
+ lastPublishedToProduction: string | null;
+ /** Whether data is loading */
+ isLoading: boolean;
+ /** Refresh the status */
+ refresh: () => Promise;
+}
+
+export function usePublishStatus({
+ projectId,
+}: UsePublishStatusOptions): UsePublishStatusResult {
+ const [lastSavedToStage, setLastSavedToStage] = useState(null);
+ const [lastPublishedToProduction, setLastPublishedToProduction] = useState<
+ string | null
+ >(null);
+ const [isLoading, setIsLoading] = useState(false);
+
+ // Track mounted state to prevent updates after unmount
+ const mountedRef = useRef(true);
+
+ const fetchStatus = useCallback(async () => {
+ if (!projectId) {
+ setLastSavedToStage(null);
+ setLastPublishedToProduction(null);
+ return;
+ }
+
+ setIsLoading(true);
+
+ try {
+ // Fetch both in parallel for performance
+ const [stageResponse, prodResponse] = await Promise.all([
+ // Last successful dev -> stage event
+ axios.get('/publish_events', {
+ params: {
+ projectId,
+ from_environment: 'dev',
+ to_environment: 'stage',
+ status: 'success',
+ limit: 1,
+ field: 'finished_at',
+ sort: 'desc',
+ },
+ }),
+ // Last successful stage -> production event
+ axios.get('/publish_events', {
+ params: {
+ projectId,
+ from_environment: 'stage',
+ to_environment: 'production',
+ status: 'success',
+ limit: 1,
+ field: 'finished_at',
+ sort: 'desc',
+ },
+ }),
+ ]);
+
+ if (!mountedRef.current) return;
+
+ setLastSavedToStage(stageResponse?.data?.rows?.[0]?.finished_at ?? null);
+ setLastPublishedToProduction(
+ prodResponse?.data?.rows?.[0]?.finished_at ?? null,
+ );
+ } catch (error) {
+ if (!mountedRef.current) return;
+ logger.error(
+ 'Failed to fetch publish status:',
+ error instanceof Error ? error : { error },
+ );
+ } finally {
+ if (mountedRef.current) {
+ setIsLoading(false);
+ }
+ }
+ }, [projectId]);
+
+ useEffect(() => {
+ mountedRef.current = true;
+ fetchStatus();
+ return () => {
+ mountedRef.current = false;
+ };
+ }, [fetchStatus]);
+
+ return {
+ lastSavedToStage,
+ lastPublishedToProduction,
+ isLoading,
+ refresh: fetchStatus,
+ };
+}
+
+export default usePublishStatus;
diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx
index be2b709..e1286d7 100644
--- a/frontend/src/pages/constructor.tsx
+++ b/frontend/src/pages/constructor.tsx
@@ -82,6 +82,7 @@ import { usePageBackground } from '../hooks/usePageBackground';
import { useConstructorData } from '../hooks/useConstructorData';
import { useAssetOptions } from '../hooks/useAssetOptions';
import { useTransitionCreation } from '../hooks/useTransitionCreation';
+import { usePublishStatus } from '../hooks/usePublishStatus';
import {
ConstructorProvider,
type ConstructorContextValue,
@@ -692,6 +693,17 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onSuccess: setSuccessMessage,
});
+ // Publish status for timestamp display
+ const { lastSavedToStage, refresh: refreshPublishStatus } = usePublishStatus({
+ projectId,
+ });
+
+ // Wrap saveToStage to refresh publish status after save
+ const handleSaveToStage = useCallback(async () => {
+ await saveToStage();
+ await refreshPublishStatus();
+ }, [saveToStage, refreshPublishStatus]);
+
useEffect(() => {
if (!router.isReady || typeof window === 'undefined') return;
@@ -1703,7 +1715,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
onAddElement={addElement}
onCreatePage={createPage}
onSave={saveConstructor}
- onSaveToStage={saveToStage}
+ onSaveToStage={handleSaveToStage}
onExit={() =>
router.push(
projectId
@@ -1711,6 +1723,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
: '/projects/projects-list',
)
}
+ lastSavedAt={activePage?.updatedAt}
+ lastSavedToStageAt={lastSavedToStage}
/>
)}
diff --git a/frontend/src/pages/projects/[projectsId].tsx b/frontend/src/pages/projects/[projectsId].tsx
index 8529c95..a9d7827 100644
--- a/frontend/src/pages/projects/[projectsId].tsx
+++ b/frontend/src/pages/projects/[projectsId].tsx
@@ -4,7 +4,6 @@ import {
mdiFileDocumentMultiple,
mdiFolderMultipleImage,
mdiPencil,
- mdiPublish,
mdiViewDashboard,
mdiWidgets,
} from '@mdi/js';
@@ -23,6 +22,8 @@ import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { logger } from '../../lib/logger';
+import { usePublishStatus } from '../../hooks/usePublishStatus';
+import dataFormatter from '../../helpers/dataFormatter';
type ProjectData = {
id: string;
@@ -49,6 +50,12 @@ const ProjectWorkspacePage = () => {
const [publishDescription, setPublishDescription] = useState('');
const [errorMessage, setErrorMessage] = useState('');
+ // Publish status for timestamp display
+ const { lastPublishedToProduction, refresh: refreshPublishStatus } =
+ usePublishStatus({
+ projectId,
+ });
+
useEffect(() => {
if (!projectId) return;
@@ -116,6 +123,7 @@ const ProjectWorkspacePage = () => {
setIsPublishModalActive(false);
setPublishTitle('');
setPublishDescription('');
+ await refreshPublishStatus();
} catch (error: unknown) {
logger.error(
'Publish failed:',
@@ -277,8 +285,12 @@ const ProjectWorkspacePage = () => {
setIsPublishModalActive(true)}
disabled={isPublishing || !projectId}