- {/* Feed actions — the page name comes from AppPage's shared PageTitle */}
-
-
setShowSearch(!showSearch)}
- aria-label="Search posts"
- title="Search posts"
- >
-
-
-
navigate('/app/notifications')}
- aria-label="Notifications"
- title="Notifications"
- >
-
-
+ {/* Feed / Drafts tabs + actions */}
+
+
+ setFeedTab('feed')}
+ className={[
+ 'rounded-md px-3 py-1 text-sm font-medium transition-colors',
+ feedTab === 'feed'
+ ? 'bg-white text-neutral-900 shadow-sm dark:bg-neutral-700 dark:text-neutral-100'
+ : 'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300',
+ ].join(' ')}
+ aria-pressed={feedTab === 'feed'}
+ >
+ Feed
+
+ setFeedTab('drafts')}
+ className={[
+ 'rounded-md px-3 py-1 text-sm font-medium transition-colors',
+ feedTab === 'drafts'
+ ? 'bg-white text-neutral-900 shadow-sm dark:bg-neutral-700 dark:text-neutral-100'
+ : 'text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300',
+ ].join(' ')}
+ aria-pressed={feedTab === 'drafts'}
+ >
+ Drafts
+
+
+
+ {feedTab === 'feed' && (
+ <>
+ setFeedView(feedView === 'chat' ? 'cards' : 'chat')}
+ aria-label={feedView === 'chat' ? 'Switch to card view' : 'Switch to chat view'}
+ title={
+ feedView === 'chat' ? 'Card view (comments & likes)' : 'Chat view (rich thread)'
+ }
+ >
+
+
+ setShowSearch(!showSearch)}
+ aria-label="Search posts"
+ title="Search posts"
+ >
+
+
+ >
+ )}
+ navigate('/app/notifications')}
+ aria-label="Notifications"
+ title="Notifications"
+ >
+
+
+
- {showSearch && (
+ {showSearch && feedTab === 'feed' && (
)}
+ {/* Drafts tab — private, multiple drafts */}
+ {selectedTeamId && feedTab === 'drafts' && user && (
+
+ )}
+
{/* Composer stays put while the feed below it scrolls */}
- {selectedTeamId && (
+ {selectedTeamId && feedTab === 'feed' && (
- {!selectedTeamId && (
-
-
- Please select a team to view the huddle feed
-
-
- )}
-
- {selectedTeamId && (
- <>
- {loading && (
-
- )}
-
- {error && (
-
- )}
-
- {!loading && !error && posts.length === 0 && (
-
-
- No posts yet. Be the first to share!
-
-
- )}
-
- {!loading &&
- !error &&
- user &&
- posts
- .filter((post) => {
- if (!searchQuery.trim()) return true;
- const query = searchQuery.toLowerCase();
- return (
- post.content.text.toLowerCase().includes(query) ||
- post.userName?.toLowerCase().includes(query) ||
- post.ticketTitle?.toLowerCase().includes(query)
- );
- })
- .map((post) => (
+ {feedTab === 'feed' && (
+
+ {!selectedTeamId && (
+
+
+ Please select a team to view the huddle feed
+
+
+ )}
+
+ {selectedTeamId && (
+ <>
+ {loading && (
+
+ )}
+
+ {error && (
+
+ )}
+
+ {!loading && !error && posts.length === 0 && (
+
+
+ No posts yet. Be the first to share!
+
+
+ )}
+
+ {/* Chat view — SuperChat thread (newest-first, read-only
+ composer: authoring goes through the RichEditor above) */}
+ {!loading && !error && user && posts.length > 0 && feedView === 'chat' && (
+
void handleMessageEdited(messageId, text)}
+ className="h-full"
+ />
+ )}
+
+ {/* Classic card view — keeps per-post comments and likes */}
+ {!loading &&
+ !error &&
+ user &&
+ feedView === 'cards' &&
+ filteredPosts.map((post) => (
))}
- >
- )}
-
+ >
+ )}
+
+ )}
);
diff --git a/src/styles.css b/src/styles.css
index f75f8a3d..2c7cef00 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -1,4 +1,5 @@
@import '@mieweb/ui/brands/bluehive.css' layer(theme);
+@import '@mieweb/ui/kerebron.css';
@import 'tailwindcss';
@import './features/teams/ychart-dark.css';
@@ -40,6 +41,67 @@ html[data-theme='dark'] {
color-scheme: dark;
}
+/* Kerebron RichEditor dark mode — the editor themes via `.kb-component--dark`
+ or `prefers-color-scheme`, but the app drives theme with `data-theme`, so map
+ its CSS variables here. Without this the editor stays light while the app is
+ dark (white box, dark toolbar icons). */
+[data-theme='dark'] .kb-component {
+ --kb-color-text: #e5e7eb; /* neutral-200 */
+ --kb-color-text-muted: #9ca3af; /* neutral-400 */
+ --kb-color-icon: #9ca3af;
+ --kb-color-surface: #171717; /* neutral-900 */
+ --kb-color-surface-elevated: #262626; /* neutral-800 */
+ --kb-color-surface-hover: rgba(255, 255, 255, 0.08);
+ --kb-color-border: #404040; /* neutral-700 */
+ --kb-color-border-strong: #525252; /* neutral-600 */
+ --kb-color-hover: rgba(129, 140, 248, 0.12);
+ --kb-color-active: rgba(129, 140, 248, 0.2);
+ --kb-color-focus: rgba(129, 140, 248, 0.25);
+
+ /* Toolbar dropdown popovers */
+ --kb-menu-dropdown-bg: #262626;
+ --kb-menu-dropdown-border: #525252;
+ --kb-menu-dropdown-text: #e5e7eb;
+ --kb-menu-dropdown-hover: rgba(255, 255, 255, 0.08);
+ --kb-menu-info-bg: #1e3a8a;
+ --kb-menu-info-text: #bfdbfe;
+}
+
+/* Toolbar popovers portal outside `.kb-component`, so theme them off the app
+ root too. */
+[data-theme='dark'] .kb-custom-menu__overflow-menu,
+[data-theme='dark'] [role='menu'].kb-custom-menu__dropdown {
+ --kb-menu-dropdown-bg: #262626;
+ --kb-menu-dropdown-border: #525252;
+ --kb-menu-dropdown-text: #e5e7eb;
+ --kb-menu-dropdown-hover: rgba(255, 255, 255, 0.08);
+}
+
+/* Kerebron ships its sticky toolbar at `z-index: 1000`, which sits ABOVE the
+ app's modal layer (@mieweb/ui Modal is `z-50`) and bleeds through any dialog
+ opened over the composer (e.g. the Pulse upload modal). Pull it back into the
+ app's z-scale: high enough to stay above editor content while scrolling, well
+ below modals/overlays. */
+.kb-component .kb-custom-menu {
+ z-index: 10;
+}
+
+/* Placeholder for MarkdownEditor. Kerebron's RichEditor takes no `placeholder`
+ prop, so the wrapper sets `data-empty` + `--markdown-editor-placeholder` and
+ the text is floated into the editor's first line — `float`/`height: 0` keeps
+ it out of layout flow so the caret still sits at the start of the line. */
+.markdown-editor[data-empty='true'] .ProseMirror::before {
+ content: var(--markdown-editor-placeholder, '');
+ float: left;
+ height: 0;
+ pointer-events: none;
+ color: var(--color-gray-400, #9ca3af);
+}
+
+[data-theme='dark'] .markdown-editor[data-empty='true'] .ProseMirror::before {
+ color: var(--color-neutral-500, #737373);
+}
+
/* Prevent iOS Safari from auto-zooming when focusing inputs smaller than 16px */
@media (max-width: 767px) {
input,
diff --git a/src/ui/BottomNav.tsx b/src/ui/BottomNav.tsx
index 4caed27a..0730811e 100644
--- a/src/ui/BottomNav.tsx
+++ b/src/ui/BottomNav.tsx
@@ -5,6 +5,8 @@
* Five tabs: Dashboard, Tickets, Clock In/Out (center FAB), Teams, Settings.
* Active tab indicator is an animated bubble that glides between positions.
* FAB uses CSS brand tokens so it follows brand/theme changes automatically.
+ * The FAB navigates to the clock page (rather than toggling directly) so the
+ * plan-first gates and their inline composer are always visible.
*/
import {
faCircleStop,
@@ -16,7 +18,7 @@ import {
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { motion, MotionConfig } from 'motion/react';
-import React, { useCallback } from 'react';
+import React from 'react';
import { useClockToggle } from '../lib/useClockToggle';
import { useRouter } from './router';
@@ -38,21 +40,12 @@ const TABS: NavTab[] = [
export const BottomNav: React.FC = () => {
const { pathname, navigate } = useRouter();
- const { isClockedIn, clockIn, clockOut, clockInLoading, clockOutLoading } = useClockToggle();
+ const { isClockedIn, planGate } = useClockToggle();
- const clockLoading = clockInLoading || clockOutLoading;
-
- const handleClockToggle = useCallback(async () => {
- try {
- if (isClockedIn) {
- await clockOut();
- } else {
- await clockIn();
- }
- } catch {
- navigate('/app/clock');
- }
- }, [isClockedIn, clockIn, clockOut, navigate]);
+ // Plan-first gate: the FAB still navigates to the clock page (where the
+ // inline composer lives), but shows a dimmed "blocked" state so it's clear
+ // clocking in/out needs today's plan or wrap-up first.
+ const planBlocked = planGate.planMissing || planGate.wrapUpMissing;
return (
@@ -69,11 +62,21 @@ export const BottomNav: React.FC = () => {
navigate(tab.href)}
+ aria-label={
+ isClockedIn
+ ? planBlocked
+ ? 'Clock Out — wrap-up required'
+ : 'Clock Out'
+ : planBlocked
+ ? 'Clock In — plan required'
+ : 'Clock In'
+ }
aria-pressed={isClockedIn}
- className="relative -top-4 flex h-16 w-16 flex-col items-center justify-center rounded-full shadow-lg transition-transform active:scale-95 disabled:opacity-60"
+ className={[
+ 'relative -top-4 flex h-16 w-16 flex-col items-center justify-center rounded-full shadow-lg transition-transform active:scale-95 disabled:opacity-60',
+ planBlocked ? 'opacity-50 saturate-50' : '',
+ ].join(' ')}
style={{
background: isClockedIn
? 'linear-gradient(135deg, #f87171, #dc2626)'
diff --git a/tests/e2e/fixtures/team.ts b/tests/e2e/fixtures/team.ts
new file mode 100644
index 00000000..2ef8982a
--- /dev/null
+++ b/tests/e2e/fixtures/team.ts
@@ -0,0 +1,52 @@
+/**
+ * Team-selection fixture helpers.
+ *
+ * The seed data provisions a shared team "Test Team Alpha" (code TEST01) that
+ * every @test.local user is a member of. Tests that need two sessions to share
+ * a feed (Huddle posts, Yjs collab editing, real-time sync) must switch away
+ * from each user's separate Personal team into this shared team, otherwise
+ * they compare two disjoint feeds.
+ *
+ * The frontend persists the active team in localStorage under
+ * `app:selectedTeamId` (plus per-org keyed variants). We set both and reload
+ * so the app boots into the shared team.
+ */
+import type { Page } from '@playwright/test';
+import { MongoClient } from 'mongodb';
+
+const MONGO_URL =
+ process.env.MONGO_URL ?? 'mongodb://127.0.0.1:27017/timehuddle_test?replicaSet=rs0';
+
+/** Look up the ObjectId of the shared seed team by its code. */
+export async function getTeamIdByCode(code: string): Promise {
+ const client = await MongoClient.connect(MONGO_URL);
+ try {
+ const team = await client.db().collection('teams').findOne({ code });
+ return team ? String(team._id) : null;
+ } finally {
+ await client.close();
+ }
+}
+
+/**
+ * Force the given page onto the shared "Test Team Alpha" (TEST01) team by
+ * writing every `app:selectedTeamId*` localStorage key and reloading.
+ *
+ * Requires the page to already be on an in-app route so localStorage is
+ * writable for the app origin.
+ */
+export async function selectSharedTestTeam(page: Page): Promise {
+ const teamId = await getTeamIdByCode('TEST01');
+ if (!teamId) {
+ throw new Error('Shared seed team TEST01 not found — did global-setup run?');
+ }
+ await page.evaluate((id) => {
+ Object.keys(localStorage)
+ .filter((k) => k.startsWith('app:selectedTeamId'))
+ .forEach((k) => localStorage.setItem(k, id));
+ localStorage.setItem('app:selectedTeamId', id);
+ }, teamId);
+ await page.reload();
+ await page.waitForLoadState('networkidle');
+ return teamId;
+}
diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts
index 2d0b7d6f..4bcf24dc 100644
--- a/tests/e2e/global-setup.ts
+++ b/tests/e2e/global-setup.ts
@@ -10,7 +10,7 @@
* - Meteor backend has been started at least once so the default organization
* exists. If it doesn't, we create it.
*/
-import { MongoClient, ObjectId } from 'mongodb';
+import { MongoClient, ObjectId, type Db } from 'mongodb';
import bcrypt from 'bcrypt';
import { createHash } from 'crypto';
@@ -64,12 +64,53 @@ const SEED_USERS = [
},
] as const;
+/**
+ * Empties the test database so every run starts from a known state.
+ *
+ * Teardown can only delete what it knows how to find — it matches seed users by
+ * `@test.local`, teams by name, and auxiliary rows by `userId` — so anything
+ * keyed differently (channels by team, tickets by creator, activities at all,
+ * signups like `onboard@example.com`) survived and accumulated run over
+ * run. Once the database is large enough, the app slows down and time-boxed
+ * waits start failing in ways that look like product bugs.
+ *
+ * Guarded on the database NAME rather than the caller's intent: this suite
+ * shares a mongod with the dev database and MONGO_URL is overridable, so refuse
+ * outright anything that isn't obviously a test database.
+ */
+async function resetTestDatabase(db: Db): Promise {
+ const name = db.databaseName;
+ if (!/_test$/.test(name)) {
+ throw new Error(
+ `[global-setup] Refusing to reset database "${name}" — expected a name ending in "_test". ` +
+ `MONGO_URL is pointing somewhere unexpected; aborting rather than risk a non-test database.`,
+ );
+ }
+
+ const collections = await db.listCollections({}, { nameOnly: true }).toArray();
+ let cleared = 0;
+ for (const { name: collName } of collections) {
+ if (collName.startsWith('system.')) continue;
+ // deleteMany rather than drop: keeps indexes and validators the backend
+ // built at startup, which it won't rebuild without a restart.
+ cleared += (await db.collection(collName).deleteMany({})).deletedCount;
+ }
+ console.log(
+ `[global-setup] ✔ Reset ${name} — cleared ${cleared} documents across ${collections.length} collections`,
+ );
+}
+
export default async function globalSetup(): Promise {
await waitForBackend(BACKEND_URL);
const client = await MongoClient.connect(MONGO_URL);
const db = client.db();
+ // Set SKIP_DB_RESET=1 to keep existing data (mirrors teardown's SKIP_CLEANUP).
+ if (!process.env.SKIP_DB_RESET) {
+ await resetTestDatabase(db);
+ }
+
// Same bcrypt(sha256hex(password)) format Meteor's Accounts.setPasswordAsync
// writes, so the Meteor login handler's Path 1 accepts it.
const digest = createHash('sha256').update(PASSWORD).digest('hex');
diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts
index 628f43eb..d35c1ce5 100644
--- a/tests/e2e/global-teardown.ts
+++ b/tests/e2e/global-teardown.ts
@@ -28,6 +28,17 @@ export default async function globalTeardown(): Promise {
const client = await MongoClient.connect(MONGO_URL);
const db = client.db();
+ // Everything below is destructive (deleteMany on users, teams, organizations)
+ // and runs against whatever MONGO_URL resolves to — which defaults to the
+ // test database but is overridable. Refuse anything that isn't clearly one,
+ // so a stray MONGO_URL can never point this at the dev database.
+ if (!/_test$/.test(db.databaseName)) {
+ await client.close();
+ throw new Error(
+ `[global-teardown] Refusing to clean database "${db.databaseName}" — expected a name ending in "_test".`,
+ );
+ }
+
try {
// ── 1. Identify all test users ──────────────────────────────────────────
// Matches seed users (*@test.local) AND any dynamically created users
diff --git a/tests/e2e/huddle/plan-first-clock-flow.spec.ts b/tests/e2e/huddle/plan-first-clock-flow.spec.ts
new file mode 100644
index 00000000..cc7851c9
--- /dev/null
+++ b/tests/e2e/huddle/plan-first-clock-flow.spec.ts
@@ -0,0 +1,409 @@
+/**
+ * Plan-First Clock Flow E2E Tests
+ *
+ * Comprehensive test suite for the plan-first clock flow feature:
+ *
+ * 1. User creates a team
+ * 2. User goes to team settings and enables "Require a plan for every clock-in/out"
+ * 3. When enabled: User must write a plan before clocking in, and add a wrap-up before clocking out
+ * 4. When disabled: User can clock in/out normally without posting to huddle
+ * 5. Posts created via clock flow appear in the huddle feed
+ * 6. Wrap-ups are appended to the same post
+ *
+ * Scenarios tested:
+ * - Plan requirement disabled (default) — can clock in/out freely
+ * - Plan requirement enabled — plan gate blocks clock in
+ * - Post plan → clock in → post wrap-up → clock out → verify in huddle
+ * - Save draft plan → clock in with draft → update wrap-up → clock out
+ */
+import { test, expect } from '@playwright/test';
+import { TeamsPage } from '../pages/TeamsPage';
+import { TeamSettingsPage } from '../pages/TeamSettingsPage';
+import { ClockPage } from '../pages/ClockPage';
+import { HuddlePage } from '../pages/HuddlePage';
+import { TEST_USERS, loginAs } from '../fixtures/users';
+
+test.describe('Plan-First Clock Flow', () => {
+ let teamsPage: TeamsPage;
+ let teamSettingsPage: TeamSettingsPage;
+ let clockPage: ClockPage;
+ let huddlePage: HuddlePage;
+
+ test.beforeEach(async ({ page }) => {
+ teamsPage = new TeamsPage(page);
+ teamSettingsPage = new TeamSettingsPage(page);
+ clockPage = new ClockPage(page);
+ huddlePage = new HuddlePage(page);
+
+ // Login as an owner who can create teams
+ await loginAs(page, TEST_USERS.owner1);
+ await page.waitForURL('**/dashboard', { timeout: 15000 });
+
+ // Start every test from a known clock state — see ensureClockedOut().
+ await clockPage.ensureClockedOut();
+ });
+
+ test('should create a team with unique name', async ({ page }) => {
+ // Navigate to teams page
+ await teamsPage.goto();
+ await expect(teamsPage.heading).toBeVisible();
+
+ // Create team
+ const teamName = `PlanFlowTest-${Date.now()}`;
+ await page.getByRole('button', { name: 'Create Team' }).click();
+ await page.getByPlaceholder('Team name').waitFor({ state: 'visible' });
+ await page.getByPlaceholder('Team name').fill(teamName);
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+
+ // Verify team appears in list
+ await expect(page.locator('main').getByText(teamName)).toBeVisible({ timeout: 10000 });
+ });
+
+ test('should toggle plan requirement in team settings', async ({ page }) => {
+ // Create a team first
+ await teamsPage.goto();
+ const teamName = `SettingsTest-${Date.now()}`;
+ await page.getByRole('button', { name: 'Create Team' }).click();
+ await page.getByPlaceholder('Team name').fill(teamName);
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+
+ // Wait for and close the "Team Created!" modal
+ await page.getByRole('button', { name: 'Done' }).click({ timeout: 10000 });
+ await page.waitForTimeout(1000);
+
+ // Open team settings by clicking the gear icon button
+ const settingsButton = page.getByRole('button', { name: 'Team Settings' }).first();
+ await settingsButton.click({ timeout: 5000 });
+
+ // Wait for modal to appear
+ await teamSettingsPage.waitForModal();
+
+ // Verify requirement is disabled by default
+ const initialState = await teamSettingsPage.isRequirePlanEnabled();
+ expect(initialState).toBe(false);
+
+ // Enable the requirement
+ await teamSettingsPage.enableRequirePlan();
+ const enabledState = await teamSettingsPage.isRequirePlanEnabled();
+ expect(enabledState).toBe(true);
+
+ // Disable it again
+ await teamSettingsPage.disableRequirePlan();
+ const disabledState = await teamSettingsPage.isRequirePlanEnabled();
+ expect(disabledState).toBe(false);
+
+ // Enable it one more time for the final state
+ await teamSettingsPage.enableRequirePlan();
+ const finalState = await teamSettingsPage.isRequirePlanEnabled();
+ expect(finalState).toBe(true);
+
+ // Close modal
+ await teamSettingsPage.close();
+ });
+
+ test('when plan requirement DISABLED: user can clock in/out without posting', async ({
+ page,
+ }) => {
+ // Create team with plan requirement disabled (default)
+ await teamsPage.goto();
+ const teamName = `NoReqTest-${Date.now()}`;
+ await page.getByRole('button', { name: 'Create Team' }).click();
+ await page.getByPlaceholder('Team name').fill(teamName);
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+
+ // Wait for and close the "Team Created!" modal
+ await page.getByRole('button', { name: 'Done' }).click({ timeout: 10000 });
+ await page.waitForTimeout(1000);
+
+ // Navigate to clock page
+ await clockPage.goto();
+
+ // Verify plan gate is NOT visible (no plan requirement)
+ const isPlanGateVisible = await clockPage.isPlanGateVisible();
+ expect(isPlanGateVisible).toBe(false);
+
+ // Clock in should be available
+ const clockInButtonVisible = await clockPage.clockInButton.isVisible();
+ expect(clockInButtonVisible).toBe(true);
+
+ // Clock in
+ await clockPage.clockIn();
+
+ // Verify we're clocked in
+ const isClockedIn = await clockPage.isClockedIn();
+ expect(isClockedIn).toBe(true);
+
+ // Clock out
+ await clockPage.clockOut();
+
+ // Verify we're clocked out
+ const stillClockedIn = await clockPage.isClockedIn();
+ expect(stillClockedIn).toBe(false);
+ });
+
+ test('when plan requirement ENABLED: plan gate blocks clock in', async ({ page }) => {
+ // Create team
+ await teamsPage.goto();
+ const teamName = `GateTest-${Date.now()}`;
+ await page.getByRole('button', { name: 'Create Team' }).click();
+ await page.getByPlaceholder('Team name').fill(teamName);
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+
+ // Wait for and close the "Team Created!" modal
+ await page.getByRole('button', { name: 'Done' }).click({ timeout: 10000 });
+ await page.waitForTimeout(1000);
+
+ // Open team settings and enable plan requirement
+ const settingsButton = page.getByRole('button', { name: 'Team Settings' }).first();
+ await settingsButton.click({ timeout: 5000 });
+
+ await teamSettingsPage.waitForModal();
+ await teamSettingsPage.enableRequirePlan();
+ await teamSettingsPage.close();
+ // Wait for team data to propagate from backend
+ await page.waitForTimeout(3000);
+
+ // Navigate to clock page
+ await clockPage.goto();
+
+ // Verify plan gate message is visible
+ const isPlanGateVisible = await clockPage.isPlanGateVisible();
+ expect(isPlanGateVisible).toBe(true);
+
+ // The plain "Clock in" button is replaced by the plan composer, so a bare
+ // clock-in is impossible until a plan is posted.
+ await expect(clockPage.postPlanAndClockInButton).toBeVisible();
+ await expect(clockPage.clockInButton).toBeHidden();
+ });
+
+ test('complete plan-first flow: post plan → clock in → post wrap-up → clock out', async ({
+ page,
+ }) => {
+ // Create team
+ await teamsPage.goto();
+ const teamName = `FlowTest-${Date.now()}`;
+ await page.getByRole('button', { name: 'Create Team' }).click();
+ await page.getByPlaceholder('Team name').fill(teamName);
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+
+ // Wait for and close the "Team Created!" modal
+ await page.getByRole('button', { name: 'Done' }).click({ timeout: 10000 });
+ await page.waitForTimeout(1000);
+
+ // Open team settings and enable plan requirement
+ const settingsButton = page.getByRole('button', { name: 'Team Settings' }).first();
+ await settingsButton.click({ timeout: 5000 });
+
+ await teamSettingsPage.waitForModal();
+ await teamSettingsPage.enableRequirePlan();
+ await teamSettingsPage.close();
+ // Wait for team data to propagate from backend
+ await page.waitForTimeout(3000);
+
+ // Navigate to clock page
+ await clockPage.goto();
+
+ // Type and post plan
+ const planText = `Complete project documentation and code review - ${Date.now()}`;
+ await clockPage.typePlan(planText);
+
+ // Post plan and clock in
+ await clockPage.postPlanAndClockIn();
+
+ // Verify clocked in state
+ const isClockedIn = await clockPage.isClockedIn();
+ expect(isClockedIn).toBe(true);
+
+ // Type wrap-up
+ const wrapUpText = `Finished documentation, started code review, will continue tomorrow`;
+ await clockPage.typeWrapUp(wrapUpText);
+
+ // Post wrap-up and clock out
+ await clockPage.postWrapUpAndClockOut();
+
+ // Verify clocked out
+ const isClockedOut = !(await clockPage.isClockedIn());
+ expect(isClockedOut).toBe(true);
+
+ // Navigate to huddle to verify post and wrap-up are there
+ await huddlePage.goto();
+
+ // Wait for posts to load
+ await page.waitForTimeout(2000);
+
+ // Verify plan is in huddle feed
+ const hasPlan = await huddlePage.hasPost(planText);
+ expect(hasPlan).toBe(true);
+
+ // Verify wrap-up is also visible (should be in the same post)
+ const hasWrapUp = await huddlePage.hasPost(wrapUpText);
+ expect(hasWrapUp).toBe(true);
+ });
+
+ test('should save draft plan and then clock in with it', async ({ page }) => {
+ // Create team
+ await teamsPage.goto();
+ const teamName = `DraftTest-${Date.now()}`;
+ await page.getByRole('button', { name: 'Create Team' }).click();
+ await page.getByPlaceholder('Team name').fill(teamName);
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+
+ // Wait for and close the "Team Created!" modal
+ await page.getByRole('button', { name: 'Done' }).click({ timeout: 10000 });
+ await page.waitForTimeout(1000);
+
+ // Open team settings and enable plan requirement
+ const settingsButton = page.getByRole('button', { name: 'Team Settings' }).first();
+ await settingsButton.click({ timeout: 5000 });
+
+ await teamSettingsPage.waitForModal();
+ await teamSettingsPage.enableRequirePlan();
+ await teamSettingsPage.close();
+ // Wait for team data to propagate from backend
+ await page.waitForTimeout(3000);
+
+ // Navigate to clock page
+ await clockPage.goto();
+
+ // Type draft plan
+ const draftPlan = `Draft plan for tomorrow - ${Date.now()}`;
+ await clockPage.typePlan(draftPlan);
+
+ // Save as draft
+ await clockPage.saveDraft();
+ await page.waitForTimeout(1000);
+
+ // Verify we can see the draft text is still there or there's feedback
+ // Draft should be saved but not posted
+
+ // Refresh and verify draft is still available
+ await clockPage.goto();
+
+ // Check if the draft content is auto-loaded
+ const editorContent = await clockPage.proseMirror.textContent();
+ if (editorContent && editorContent.includes(draftPlan)) {
+ // Draft was preserved
+ expect(editorContent).toContain(draftPlan);
+ }
+
+ // Now clock in using the draft
+ await clockPage.postPlanAndClockIn();
+
+ // Verify we're clocked in
+ const isClockedIn = await clockPage.isClockedIn();
+ expect(isClockedIn).toBe(true);
+
+ // Clock out — the gate is on, so a wrap-up has to be posted first (the
+ // button stays disabled while the composer is empty).
+ await clockPage.typeWrapUp('Wrapped up the drafted plan');
+ await clockPage.postWrapUpAndClockOut();
+ expect(await clockPage.isClockedIn()).toBe(false);
+ });
+
+ test('should handle disabling/re-enabling requirement during session', async ({ page }) => {
+ // Create team
+ await teamsPage.goto();
+ const teamName = `ToggleTest-${Date.now()}`;
+ await page.getByRole('button', { name: 'Create Team' }).click();
+ await page.getByPlaceholder('Team name').fill(teamName);
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+
+ // Wait for and close the "Team Created!" modal
+ await page.getByRole('button', { name: 'Done' }).click({ timeout: 10000 });
+ await page.waitForTimeout(1000);
+
+ // Navigate to clock page (requirement disabled by default)
+ await clockPage.goto();
+
+ // Verify no plan gate
+ const noPlanGate = !(await clockPage.isPlanGateVisible());
+ expect(noPlanGate).toBe(true);
+
+ // Clock in without plan
+ await clockPage.clockIn();
+ expect(await clockPage.isClockedIn()).toBe(true);
+
+ // Clock out
+ await clockPage.clockOut();
+ expect(await clockPage.isClockedIn()).toBe(false);
+
+ // Now enable the requirement in settings
+ await teamsPage.goto();
+ await page.waitForTimeout(500);
+ const settingsButton = page.getByRole('button', { name: 'Team Settings' }).first();
+ await settingsButton.click({ timeout: 5000 });
+
+ await teamSettingsPage.waitForModal();
+ await teamSettingsPage.enableRequirePlan();
+ await teamSettingsPage.close();
+ await page.waitForTimeout(1500);
+
+ // Go back to clock page
+ await clockPage.goto();
+
+ // Now plan gate should be visible
+ const planGateNowVisible = await clockPage.isPlanGateVisible();
+ expect(planGateNowVisible).toBe(true);
+ });
+
+ test('should verify wrap-up is appended to same post (not create new post)', async ({ page }) => {
+ // Create team
+ await teamsPage.goto();
+ const teamName = `AppendTest-${Date.now()}`;
+ await page.getByRole('button', { name: 'Create Team' }).click();
+ await page.getByPlaceholder('Team name').fill(teamName);
+ await page.getByRole('button', { name: 'Create', exact: true }).click();
+
+ // Wait for and close the "Team Created!" modal
+ await page.getByRole('button', { name: 'Done' }).click({ timeout: 10000 });
+ await page.waitForTimeout(1000);
+
+ // Enable plan requirement
+ const settingsButton = page.getByRole('button', { name: 'Team Settings' }).first();
+ await settingsButton.click({ timeout: 5000 });
+
+ await teamSettingsPage.waitForModal();
+ await teamSettingsPage.enableRequirePlan();
+ await teamSettingsPage.close();
+ // Wait for team data to propagate from backend
+ await page.waitForTimeout(3000);
+
+ // Complete clock flow
+ await clockPage.goto();
+
+ const uniqueMarker = `unique-session-${Date.now()}`;
+ const planText = `Morning standup - ${uniqueMarker}`;
+ const wrapUpText = `Tasks completed today - ${uniqueMarker}`;
+
+ await clockPage.typePlan(planText);
+ await clockPage.postPlanAndClockIn();
+
+ // Wait a bit to ensure clock in is recorded
+ await page.waitForTimeout(2000);
+
+ // Add wrap-up
+ await clockPage.typeWrapUp(wrapUpText);
+ await clockPage.postWrapUpAndClockOut();
+
+ // Navigate to huddle
+ await huddlePage.goto();
+ await page.waitForTimeout(2000);
+
+ // Verify both plan and wrap-up are in the same post (by checking they appear together)
+ const posts = await huddlePage.getVisiblePosts();
+
+ // Look for a post that contains the unique marker
+ let foundPost = false;
+ for (const post of posts) {
+ if (post.includes(uniqueMarker)) {
+ foundPost = true;
+ // Verify both plan and wrap-up content are in the same post
+ expect(post).toContain(planText);
+ expect(post).toContain(wrapUpText);
+ break;
+ }
+ }
+
+ expect(foundPost).toBe(true);
+ });
+});
diff --git a/tests/e2e/huddle/pulsevault-video.spec.ts b/tests/e2e/huddle/pulsevault-video.spec.ts
index 4b9f9def..edbd6088 100644
--- a/tests/e2e/huddle/pulsevault-video.spec.ts
+++ b/tests/e2e/huddle/pulsevault-video.spec.ts
@@ -17,12 +17,30 @@ import { expect, test } from '@playwright/test';
import { TEST_USERS, loginAs } from '../fixtures/users';
import { createTicket, deleteTicket, uploadVideoToTicket, TEST_MP4 } from '../tickets/helpers';
+/**
+ * The composer's editable surface. Kerebron's RichEditor renders a ProseMirror
+ * contenteditable rather than a