From 2a2a7b85203bf52ff87f34bebc6492118fc86b08 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 3 Aug 2026 12:09:17 -0400 Subject: [PATCH 01/66] Add redesign prototype and mobile/desktop implementation plan --- design/redesign-prototype/index.html | 1433 ++++++++++++++++++++++++ docs/redesign-implementation-prompt.md | 219 ++++ 2 files changed, 1652 insertions(+) create mode 100644 design/redesign-prototype/index.html create mode 100644 docs/redesign-implementation-prompt.md diff --git a/design/redesign-prototype/index.html b/design/redesign-prototype/index.html new file mode 100644 index 00000000..1512a0df --- /dev/null +++ b/design/redesign-prototype/index.html @@ -0,0 +1,1433 @@ + + + + + +TimeHuddle β€” Redesign Prototype + + + + + + + + +
+ + + + + diff --git a/docs/redesign-implementation-prompt.md b/docs/redesign-implementation-prompt.md new file mode 100644 index 00000000..afbd0b3b --- /dev/null +++ b/docs/redesign-implementation-prompt.md @@ -0,0 +1,219 @@ +# TimeHuddle UI/UX Redesign β€” Implementation Prompt + +> Use this document as the instruction set for the actual implementation pass on +> `src/`. It is based on the approved layout/UX from +> [design/redesign-prototype/index.html](../design/redesign-prototype/index.html) +> and an audit of the real, existing app structure. **No feature, component, route, +> API call, or business logic may be removed.** This is a re-layout/re-skin pass: +> existing components get moved, restyled, and re-composed into the new +> structure β€” not rewritten from scratch, and not deleted. + +## How To Execute This Prompt + +Paste this whole file (or just the "Execution Plan" section below) to an +implementation agent, one step at a time, in order. Each step is small, +independently testable, and ends with a validation command. Do not start step +`N+1` until step `N`'s validation passes. Steps 1–8 are Phase 1 (mobile). +Steps 9–15 are Phase 2 (desktop). Steps 16–17 are final verification. + +## Execution Plan (Step-by-Step) + +**Phase 1 β€” Mobile** + +1. Confirm/adjust [BottomNav.tsx](../src/ui/BottomNav.tsx) tab set: Dashboard, + Huddle, Clock (center FAB), Tickets, Teams. Move Settings behind the + profile/overflow menu. Validate: `npm run typecheck` + manual tap-through + at a 390Γ—844 viewport. +2. Locate the shared `Modal` primitive used across the app (`@mieweb/ui` + `Modal`, or the app's own wrapper if one exists) and add/confirm a mobile + bottom-sheet variant (slide up, full width, rounded top corners only, + `max-h-[88vh]` with internal scroll). Apply it to: ticket create/detail + modals, team edit modal, org edit modal, invite modal, confirm-delete + modal. Validate: open each modal at mobile width, confirm slide-up behavior + and that all existing buttons in them still work. +3. Restyle [Huddle.tsx](../src/pages/Huddle.tsx) + + [PostCard/](../src/features/huddle/PostCard/) for mobile: edge-to-edge + cards, no side margins/radius, thin divider borders, composer pinned above + the feed. Validate: create a post, confirm it still saves/renders via + existing `HuddleComposer.tsx` + `api.ts` logic (no logic changes). +4. In [DashboardPage.tsx](../src/features/dashboard/DashboardPage.tsx), make + the stat card grid 2-column on mobile below the existing Me/Team toggle + (toggle itself unchanged). Validate: numbers still populate correctly for + both Me and Team modes. +5. In [ClockPage.tsx](../src/features/clock/ClockPage.tsx), reorder to a + single mobile column: status/timer β†’ plan editor β†’ clock button (full + width). Validate: plan-first gate still blocks clock in/out without a + plan/wrap-up (`useClockToggle.test.ts` must still pass). +6. Restyle [TicketsPage.tsx](../src/features/tickets/TicketsPage.tsx) filters + as a horizontally scrollable chip row on mobile. Validate: filtering still + returns correct results; GitHub/Redmine source badges still visible. +7. Restyle [TeamsPage.tsx](../src/features/teams/TeamsPage.tsx), + [OrganizationPage.tsx](../src/features/org/OrganizationPage.tsx), and + [ProfilePage.tsx](../src/features/profile/ProfilePage.tsx) to single-column + stacked layouts on mobile. Validate: edit/delete/invite actions on each + still function. +8. Confirm the app's toast/notification primitive renders full-width near the + top on mobile. Validate: trigger a clock-in, ticket edit, and post-create + toast; all appear correctly. + β†’ **Phase 1 done. Run `npm run lint && npm run typecheck && npm run test:all`, + fix anything red, then stop and get sign-off before Phase 2.** + +**Phase 2 β€” Desktop/Browser** + +9. Confirm [Sidebar.tsx](../src/ui/Sidebar.tsx) `NAV` grouping matches: + Workspace (Dashboard, Huddle, Clock, Tickets, Timesheet, Work), Manage + (Teams, Organization, Media, Messages, Notifications, Activity). Reorder + only β€” no items removed. Validate: every sidebar link still routes + correctly. +10. Add the desktop centered-dialog variant to the same `Modal` primitive + touched in step 2 (rounded on all corners, `max-w-md`/`max-w-lg`, + click-outside-to-dismiss). Validate: same modals as step 2, now at full + desktop width. +11. In `DashboardPage.tsx`, expand stat cards to a 4-column row inside a + centered `max-w-4xl` column at desktop widths. Validate: layout doesn't + break at 1280px/1440px/1920px. +12. Restyle the Huddle feed to a centered `max-w-2xl` column with visible + card borders/radius and spacing at desktop widths. Validate: feed and + composer still fully functional. +13. In `ClockPage.tsx`, cap the layout to a centered `max-w-2xl` column at + desktop widths. Validate: plan-first gate still enforced. +14. Restyle Tickets/Teams/Organization/Profile pages to centered + `max-w-3xl`/`max-w-4xl` columns with filters in a single horizontal row + at desktop widths. Validate: all CRUD actions still function. +15. Confirm toasts shrink-to-content and center at desktop widths. + β†’ **Phase 2 done. Run `npm run lint && npm run typecheck && npm run test:all`.** + +**Final Verification** + +16. Manual pass: open every item in both nav bars (mobile bottom nav + + desktop sidebar) at both a mobile viewport and full desktop width; + confirm no dead links, no missing pages, no broken buttons. +17. Confirm every acceptance criterion in the "Acceptance Criteria" section + below is checked off before calling this complete. + +## Hard Constraints + +- Do not delete any file in `src/features/**` or `src/ui/**` unless it becomes a + literal duplicate after merging (confirm with the user first either way). +- Do not remove any route in [src/ui/router.ts](../src/ui/router.ts) or any page + currently reachable from [src/ui/Sidebar.tsx](../src/ui/Sidebar.tsx) / + [src/ui/BottomNav.tsx](../src/ui/BottomNav.tsx). If a page's *content* moves + inside another page (e.g. tabs), the route can redirect/alias β€” it must not 404. + keep all existing lower level components +- Every existing `@mieweb/ui` usage stays `@mieweb/ui`. Do not introduce raw + ` {createPortal( - + Switch organization / team {organizations.length > 0 && ( diff --git a/tests/e2e/teams/profile-routing.spec.ts b/tests/e2e/teams/profile-routing.spec.ts index 405e9913..3df46ca5 100644 --- a/tests/e2e/teams/profile-routing.spec.ts +++ b/tests/e2e/teams/profile-routing.spec.ts @@ -76,11 +76,7 @@ test.describe('Profile Routing', () => { return; } - // Switch to Members tab - const membersTab = page.getByRole('tab', { name: 'Members' }); - await membersTab.waitFor({ state: 'visible', timeout: 15000 }); - await membersTab.click(); - + // Members are always visible on the Teams page β€” no tab to switch to. // Click the first member button (View … profile) const memberButton = page.getByRole('button', { name: /view .+'s profile/i }).first(); await memberButton.waitFor({ state: 'visible', timeout: 10000 }); @@ -112,11 +108,7 @@ test.describe('Profile Routing', () => { return; } - // Switch to Members tab - const membersTab = page.getByRole('tab', { name: 'Members' }); - await membersTab.waitFor({ state: 'visible', timeout: 15000 }); - await membersTab.click(); - + // Members are always visible on the Teams page β€” no tab to switch to. // Click Test Member One specifically const memberButton = page.getByRole('button', { name: /view test member one's profile/i, diff --git a/tests/e2e/teams/teams.spec.ts b/tests/e2e/teams/teams.spec.ts index 3a40ec1f..b5c3c8ea 100644 --- a/tests/e2e/teams/teams.spec.ts +++ b/tests/e2e/teams/teams.spec.ts @@ -4,9 +4,8 @@ * 1. Join team (already tested elsewhere, so skip here) * 2. Create team - verify team code generated * 3. Teams page has correct URL - * 4. Admin: Teams page has Timesheet and Members tabs - * 5. Team timesheet filters work - * 6. Team members are shown correctly + * 4. Team members are shown correctly (no tabs β€” Members is always visible) + * 5. Admin: Dashboard's Team tab has a Timesheet view with working filters */ import { test, expect } from '@playwright/test'; import { MongoClient } from 'mongodb'; @@ -84,67 +83,6 @@ test.describe('Teams', () => { }); }); - test('admin should see Members and Timesheet tabs with working filters', async ({ page }) => { - test.setTimeout(60000); - - // Ensure Test Team Alpha is selected - const teamId = await getTestTeamId(); - if (!teamId) { - test.skip(true, 'Test Team Alpha not found'); - return; - } - - await gotoTeamsPage(page); - - // Set Test Team Alpha as selected team via localStorage and reload - 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 expect(page.getByRole('button', { name: 'Create Team' })).toBeVisible({ timeout: 20000 }); - - // Wait for the team to load β€” the Timesheet tab only appears for non-personal teams - const timesheetTab = page.getByRole('tab', { name: 'Timesheet' }); - const membersTab = page.getByRole('tab', { name: 'Members' }); - - // If Personal Workspace is still showing, the localStorage didn't take effect. - // Try a direct navigation with deep-link query param. - if (!(await timesheetTab.isVisible({ timeout: 5000 }).catch(() => false))) { - await page.goto(`/app/teams?teamId=${teamId}`); - await expect(page.getByRole('button', { name: 'Create Team' })).toBeVisible({ - timeout: 20000, - }); - } - - // If Timesheet tab still not visible, skip β€” Test Team Alpha may not be available for this user - if (!(await timesheetTab.isVisible({ timeout: 10000 }).catch(() => false))) { - test.skip(true, 'Timesheet tab not available β€” team may not have loaded'); - return; - } - - // Verify tabs are visible - await expect(membersTab).toBeVisible(); - - // Click Timesheet tab - await timesheetTab.click(); - await page.waitForTimeout(3000); - - // Verify the admin timesheet panel loaded with date range buttons - await expect(page.getByRole('button', { name: 'Today', exact: true })).toBeVisible({ - timeout: 15000, - }); - await expect(page.getByRole('button', { name: 'This Week', exact: true })).toBeVisible(); - - // Click different presets to verify they work - await page.getByRole('button', { name: 'Today', exact: true }).click(); - await page.waitForTimeout(500); - await page.getByRole('button', { name: 'This Week', exact: true }).click(); - await page.waitForTimeout(500); - }); - test('team members are shown correctly', async ({ page }) => { await gotoTeamsPage(page); await page.waitForTimeout(1000); @@ -165,10 +103,7 @@ test.describe('Teams', () => { await page.waitForTimeout(1500); } - // Click Members tab - await page.getByRole('tab', { name: 'Members' }).click(); - await page.waitForTimeout(1000); - + // Members are always visible β€” no tab to click. // Verify at least the current user is shown (use the profile button to be specific) await expect(page.getByRole('button', { name: /View Test Owner One/ })).toBeVisible({ timeout: 5000, @@ -177,4 +112,44 @@ test.describe('Teams', () => { // Verify members count is shown await expect(page.getByText(/Members \(\d+\)/)).toBeVisible(); }); + + test('admin should see a Timesheet view on the Dashboard Team tab with working filters', async ({ + page, + }) => { + test.setTimeout(60000); + + const teamId = await getTestTeamId(); + if (!teamId) { + test.skip(true, 'Test Team Alpha not found'); + return; + } + + await page.goto(`/app/dashboard?teamId=${teamId}`); + await expect(page.getByRole('button', { name: 'Team', exact: true })).toBeVisible({ + timeout: 20000, + }); + + await page.getByRole('button', { name: 'Team', exact: true }).click(); + + const timesheetToggle = page.getByRole('button', { name: 'Timesheet', exact: true }); + if (!(await timesheetToggle.isVisible({ timeout: 10000 }).catch(() => false))) { + test.skip(true, 'Timesheet view not available β€” team may not have loaded'); + return; + } + + await timesheetToggle.click(); + await page.waitForTimeout(3000); + + // Verify the admin timesheet panel loaded with date range buttons + await expect(page.getByRole('button', { name: 'Today', exact: true })).toBeVisible({ + timeout: 15000, + }); + await expect(page.getByRole('button', { name: 'This Week', exact: true })).toBeVisible(); + + // Click different presets to verify they work + await page.getByRole('button', { name: 'Today', exact: true }).click(); + await page.waitForTimeout(500); + await page.getByRole('button', { name: 'This Week', exact: true }).click(); + await page.waitForTimeout(500); + }); }); diff --git a/tests/e2e/timesheet/timesheet-calculation-fixes.spec.ts b/tests/e2e/timesheet/timesheet-calculation-fixes.spec.ts index 75edbbfc..70af95ad 100644 --- a/tests/e2e/timesheet/timesheet-calculation-fixes.spec.ts +++ b/tests/e2e/timesheet/timesheet-calculation-fixes.spec.ts @@ -219,23 +219,24 @@ test.describe('Timesheet calculation fixes', () => { // ── Admin's view of the same member: same 1-day grouping ── // Uses the app's documented deep-link support // (?tab=timesheet&teamId=&memberId=) to land straight on the admin - // timesheet panel with this member pre-selected. + // Timesheet view on the Dashboard's "Team" tab, member pre-selected. const adminContext = await browser.newContext({ timezoneId: 'America/New_York' }); const adminPage = await adminContext.newPage(); try { await loginAs(adminPage, TEST_USERS.admin1); await adminPage.goto( - `/app/teams?tab=timesheet&teamId=${teamId}&memberId=${memberUserId}`, + `/app/dashboard?tab=timesheet&teamId=${teamId}&memberId=${memberUserId}`, ); // The deep-link's `tab` and `memberId` are applied unconditionally, // but `teamId` is matched against the loaded team list β€” so when // teams arrive after the handler runs (routinely, late in a suite // run) the team selection is dropped and Personal stays selected, - // which renders no Timesheet tab at all. Select the team explicitly, - // same as the member's own view above. + // which renders no Timesheet toggle at all. Select the team + // explicitly, same as the member's own view above. await adminPage.getByRole('button', { name: /Switch organization and team/i }).click(); await adminPage.getByRole('menuitem', { name: 'Test Team Alpha' }).click(); - await adminPage.getByRole('tab', { name: 'Timesheet' }).click(); + await adminPage.getByRole('button', { name: 'Team', exact: true }).click(); + await adminPage.getByRole('button', { name: 'Timesheet', exact: true }).click(); // Wait for the admin timesheet panel itself to mount. await adminPage From d3af9e5ec161da5b3e173175ba298d5061f05dfe Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 3 Aug 2026 17:32:51 -0400 Subject: [PATCH 24/66] Clock page: full attach bar, bold session timer, recent sessions Give the plan/wrap-up composer the same Photo/Video/Doc/Pulse/Ticket/ @Mention bar as the Huddle composer (extracted into shared ComposerAttachButtons/ComposerChips so both stay in sync), and match the redesign prototype's status card (bold elapsed-session timer instead of the wall clock) and Recent sessions list. --- src/features/clock/ClockPage.tsx | 350 +++++++++++++------- src/features/huddle/ComposerAttachments.tsx | 152 +++++++++ src/features/huddle/HuddleComposer.tsx | 117 ++----- 3 files changed, 409 insertions(+), 210 deletions(-) create mode 100644 src/features/huddle/ComposerAttachments.tsx diff --git a/src/features/clock/ClockPage.tsx b/src/features/clock/ClockPage.tsx index 06ef4692..bfdb8373 100644 --- a/src/features/clock/ClockPage.tsx +++ b/src/features/clock/ClockPage.tsx @@ -2,46 +2,44 @@ * ClockPage β€” plan-first shift screen. * * Reads top-to-bottom as a gate rather than a dashboard: - * 1. Banner β€” status lamp + one plain sentence that always says what's - * blocking you (Ready to work β†’ Plan posted β†’ On shift). - * 2. Composer β€” plain textarea with a single combined action: "Post plan - * and clock in" / "Post wrap-up and clock out" (⌘/Ctrl+↡ submits). - * 3. Clock module β€” compact seven-segment punch clock pinned near the - * bottom with a live status readout line. + * 1. Status β€” eyebrow + a big bold session timer (elapsed time this + * shift, not the wall clock) + a "plan required" badge when the team + * gate is on. + * 2. Composer β€” plan-before-clock-in / wrap-up-before-clock-out, with the + * same Photo/Video/Doc/Pulse/Ticket/@Mention bar as the Huddle composer + * (⌘/Ctrl+↡ submits). + * 3. Recent sessions β€” the user's last completed sessions on this team. * * Gate state comes from useClockToggle.planGate (realtime via DDP), so this * page never needs a reload. With the team setting off, it's a plain * clock-in/out screen. */ -import { Button, Spinner, Text } from '@mieweb/ui'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Spinner, Text } from '@mieweb/ui'; import React, { useEffect, useRef, useState } from 'react'; -import { huddleApi, type HuddlePost } from '../../lib/api'; +import { clockApi, huddleApi, type ClockEvent, type HuddlePost } from '../../lib/api'; import { getDdpClient } from '../../lib/ddp'; import { useTeam } from '../../lib/TeamContext'; -import { formatTimer, getActiveClockSeconds, toDateString } from '../../lib/timeUtils'; +import { + formatDate, + formatDuration, + formatTime, + formatTimer, + getActiveClockSeconds, + toDateString, +} from '../../lib/timeUtils'; import { useClockToggle } from '../../lib/useClockToggle'; import { MarkdownEditor } from '../huddle/MarkdownEditor'; +import { toPostAttachment } from '../huddle/api'; +import { + ComposerAttachButtons, + ComposerChips, + type MentionRef, +} from '../huddle/ComposerAttachments'; +import type { MediaItem } from '../huddle/types'; import { AppPage } from '../../ui/AppPage'; import { useRouter } from '../../ui/router'; -// Figure space keeps single-digit hours aligned against the 88:88:88 backdrop. -const FIGURE_SPACE = '\u2007'; - -function clockParts(now: number) { - const d = new Date(now); - let hours = d.getHours() % 12; - if (hours === 0) hours = 12; - const hh = String(hours).padStart(2, FIGURE_SPACE); - const mm = String(d.getMinutes()).padStart(2, '0'); - const ss = String(d.getSeconds()).padStart(2, '0'); - const meridiem = d.getHours() >= 12 ? 'PM' : 'AM'; - const date = d - .toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric' }) - .toUpperCase(); - return { time: `${hh}:${mm}:${ss}`, meridiem, date }; -} - // ─── ClockPage ──────────────────────────────────────────────────────────────── export const ClockPage: React.FC = () => { @@ -87,6 +85,55 @@ export const ClockPage: React.FC = () => { const [savingDraft, setSavingDraft] = useState(false); const [draftSaved, setDraftSaved] = useState(false); + // ── Attach/ticket/mention controls β€” same action bar as the Huddle composer ── + const [attachments, setAttachments] = useState([]); + const [selectedTicketId, setSelectedTicketId] = useState(undefined); + const [mentions, setMentions] = useState([]); + const handleAttachmentAdd = (media: MediaItem) => setAttachments((prev) => [...prev, media]); + const handleAttachmentRemove = (mediaId: string) => + setAttachments((prev) => prev.filter((m) => m.id !== mediaId)); + const handleMentionSelect = (userId: string, name: string) => + setMentions((prev) => + prev.some((m) => m.userId === userId) ? prev : [...prev, { userId, name }], + ); + const handleMentionRemove = (userId: string) => + setMentions((prev) => prev.filter((m) => m.userId !== userId)); + + // ── Recent sessions β€” the user's last completed sessions on this team ── + const recentSessionsTeamId = gateTeamId ?? selectedTeamId; + const [recentSessions, setRecentSessions] = useState([]); + const [recentSessionsLoading, setRecentSessionsLoading] = useState(true); + + useEffect(() => { + if (!recentSessionsTeamId) { + setRecentSessions([]); + setRecentSessionsLoading(false); + return; + } + let cancelled = false; + setRecentSessionsLoading(true); + clockApi + .getEvents() + .then((events) => { + if (cancelled) return; + const completed = events + .filter((e) => e.teamId === recentSessionsTeamId && e.endTime != null) + .sort((a, b) => b.startTime - a.startTime) + .slice(0, 8); + setRecentSessions(completed); + }) + .catch(() => { + if (!cancelled) setRecentSessions([]); + }) + .finally(() => { + if (!cancelled) setRecentSessionsLoading(false); + }); + return () => { + cancelled = true; + }; + // Re-fetch whenever a session finishes so the list stays current. + }, [recentSessionsTeamId, isClockedIn]); + const composerMode: 'plan' | 'wrapup' | null = !isClockedIn ? planMissing ? 'plan' @@ -141,21 +188,41 @@ export const ClockPage: React.FC = () => { setEditorKey((k) => k + 1); }, [composerMode, seedText]); + // Clear attach/ticket/mention selections whenever the composer opens fresh + // (mode switches between plan/wrap-up/hidden, e.g. after a successful post). + useEffect(() => { + setAttachments([]); + setSelectedTicketId(undefined); + setMentions([]); + }, [composerMode]); + async function saveDraft() { const trimmed = text.trim(); if (!gateTeamId || !trimmed || savingDraft || posting) return; setSavingDraft(true); setPostError(null); try { + const mentionUserIds = mentions.length ? mentions.map((m) => m.userId) : undefined; + const postAttachments = attachments.map(toPostAttachment); if (draft) { - await huddleApi.updatePost(draft.id, { - text: trimmed, - mentions: draft.content.mentions, - }); + await huddleApi.updatePost( + draft.id, + { text: trimmed, mentions: mentionUserIds ?? draft.content.mentions }, + { + attachments: postAttachments.length ? postAttachments : undefined, + ticketId: selectedTicketId, + }, + ); setDraft({ ...draft, content: { ...draft.content, text: trimmed } }); } else { - const created = await huddleApi.saveDraft(gateTeamId, { text: trimmed, mentions: [] }); - setDraft({ id: created.id, content: { text: trimmed, mentions: [] } }); + const created = (await getDdpClient().call('huddle.createPost', { + teamId: gateTeamId, + content: { text: trimmed, mentions: mentionUserIds ?? [] }, + ticketId: selectedTicketId, + attachments: postAttachments, + draft: true, + })) as { id: string }; + setDraft({ id: created.id, content: { text: trimmed, mentions: mentionUserIds ?? [] } }); } setDraftSaved(true); setTimeout(() => setDraftSaved(false), 2500); @@ -173,18 +240,33 @@ export const ClockPage: React.FC = () => { setPostError(null); try { let planPostId: string; + const mentionUserIds = mentions.length ? mentions.map((m) => m.userId) : undefined; + const postAttachments = attachments.map(toPostAttachment); if (draft) { // Publishing the draft (with any edits) is this session's plan post. + const publishedMentions = mentionUserIds ?? draft.content.mentions; await huddleApi.publishPost(draft.id, toDateString(new Date()), { text: trimmed, - mentions: draft.content.mentions, + mentions: publishedMentions, }); planPostId = draft.id; + if (postAttachments.length > 0 || selectedTicketId) { + await huddleApi.updatePost( + planPostId, + { text: trimmed, mentions: publishedMentions }, + { + attachments: postAttachments.length ? postAttachments : undefined, + ticketId: selectedTicketId, + }, + ); + } setDraft(null); } else { const created = (await getDdpClient().call('huddle.createPost', { teamId: gateTeamId, - content: { text: trimmed, mentions: [] }, + content: { text: trimmed, mentions: mentionUserIds ?? [] }, + ticketId: selectedTicketId, + attachments: postAttachments, postDate: toDateString(new Date()), })) as { id: string }; planPostId = created.id; @@ -212,25 +294,33 @@ export const ClockPage: React.FC = () => { // cached post ID (handles the race where the plan post was just created // but hasn't arrived via DDP subscription yet). const effectivePostId = sessionPost?.id ?? cachedPlanPostIdRef.current; + const mentionUserIds = mentions.length ? mentions.map((m) => m.userId) : undefined; + const postAttachments = attachments.map(toPostAttachment); if (effectivePostId) { // Normal flow: update the plan post with the wrap-up. await huddleApi.updatePost( effectivePostId, { text: trimmed, - mentions: sessionPost?.content.mentions ?? [], + mentions: mentionUserIds ?? sessionPost?.content.mentions ?? [], + }, + { + wrapUp: true, + attachments: postAttachments.length ? postAttachments : undefined, + ticketId: selectedTicketId, }, - { wrapUp: true }, ); } else { // Recovery: no plan post exists (gate enabled mid-shift). Create one // that doubles as the wrap-up, linked to the session. await getDdpClient().call('huddle.createPost', { teamId: gateTeamId, - content: { text: `**Wrap-up:** ${trimmed}`, mentions: [] }, + content: { text: `**Wrap-up:** ${trimmed}`, mentions: mentionUserIds ?? [] }, postDate: toDateString(new Date()), clockEventId: activeClockEvent.id, wrapUp: true, + ticketId: selectedTicketId, + attachments: postAttachments, }); } setText(''); @@ -243,44 +333,32 @@ export const ClockPage: React.FC = () => { } } - // ── Banner copy β€” always says what's blocking you ── + // ── Status card copy ── + const eyebrow = !isClockedIn ? 'Clocked out' : isPaused ? 'On break' : 'Clocked in'; + + // ── Composer card copy β€” always says what's blocking you ── const teamSuffix = teamName && gateTeamId !== selectedTeamId ? ` in β€œ${teamName}”` : ''; - let eyebrow: string; - let headline: string; - let subline: React.ReactNode = null; - if (!isClockedIn) { - eyebrow = 'Ready to work'; - if (composerMode === 'plan') { - headline = 'Write a plan before starting this session.'; - subline = ( - <> - Posting starts your shift.{' '} - - - ); - } else if (requirePlan) { - headline = 'Plan posted β€” you’re set to clock in.'; - } else { - headline = 'You’re set to clock in.'; - } - } else { - eyebrow = isPaused ? 'On break' : 'On shift'; - if (composerMode === 'wrapup') { - headline = `Add a wrap-up to this session’s post${teamSuffix} before clocking out.`; - subline = 'Posting ends your shift.'; - } else { - headline = `Clocked in β€” ${formatTimer(sessionSeconds)} this shift.`; - } + const composerTitle = + composerMode === 'plan' ? 'Plan before you clock in' : 'Wrap up before you clock out'; + let composerDescription: React.ReactNode = null; + if (composerMode === 'plan') { + composerDescription = ( + <> + This team requires a short plan before clocking in. It's posted to Huddle so your team can + see what you're working on.{' '} + + + ); + } else if (composerMode === 'wrapup') { + composerDescription = `Add a quick wrap-up of what you did this session${teamSuffix} before clocking out.`; } - const { time, meridiem, date } = clockParts(currentTime); - if (!teamsReady) { return (
@@ -291,13 +369,13 @@ export const ClockPage: React.FC = () => { return ( -
- {/* ── Banner β€” the gate, in one sentence ── */} +
+ {/* ── Status β€” eyebrow + big bold session timer ── */}
-
+
{ /> {eyebrow}
- - {headline} - - {subline &&

{subline}

} +
+ {formatTimer(sessionSeconds)} +
+ {activeClockEvent && ( +

+ since {formatTime(new Date(activeClockEvent.startTime))} +

+ )} + {requirePlan && ( + + Plan required for this team + + )}
- {/* ── Composer β€” one box, one combined action ── */} + {/* ── Composer β€” plan before clock-in / wrap-up before clock-out ── */} {composerMode && (
+
+ + {composerTitle} + + {composerDescription && ( + + {composerDescription} + + )} +
+ { void (composerMode === 'plan' ? postPlanAndClockIn() : postWrapUpAndClockOut()) } /> + + {/* ── Ticket / mention / attachment chips ── */} + setSelectedTicketId(undefined)} + mentions={mentions} + onMentionRemove={handleMentionRemove} + attachments={attachments} + onAttachmentRemove={handleAttachmentRemove} + /> + + {/* ── Attach bar β€” same Photo/Video/Doc/Pulse/Ticket/@Mention controls as Huddle ── */} +
+ +
+
); diff --git a/src/features/huddle/ComposerAttachments.tsx b/src/features/huddle/ComposerAttachments.tsx new file mode 100644 index 00000000..7e5e9f7d --- /dev/null +++ b/src/features/huddle/ComposerAttachments.tsx @@ -0,0 +1,152 @@ +/** + * Shared attach/ticket/mention controls for post composers β€” the action bar + * and chip rows factored out of HuddleComposer so other composers (e.g. the + * Clock page's plan/wrap-up composer) can offer the same Photo/Video/Doc/ + * Pulse/Ticket/@Mention affordances without duplicating the markup. + */ +import { AttachmentBar } from './AttachmentBar'; +import { PulseAttachButton } from './PulseAttachButton'; +import { TicketPicker } from './TicketPicker'; +import { MentionMenu } from './MentionMenu'; +import type { MediaItem } from './types'; + +export type MentionRef = { userId: string; name: string }; + +interface ComposerAttachButtonsProps { + teamId?: string | null; + onAttachmentAdd: (media: MediaItem) => void; + selectedTicketId?: string; + onTicketSelect: (ticketId: string) => void; + onMentionSelect: (userId: string, name: string) => void; +} + +/** The Photo / Video / Doc / Pulse / Ticket / @Mention button row. */ +export function ComposerAttachButtons({ + teamId, + onAttachmentAdd, + selectedTicketId, + onTicketSelect, + onMentionSelect, +}: ComposerAttachButtonsProps) { + return ( + <> + + + {teamId && ( + + )} + {teamId && } + + ); +} + +interface ComposerChipsProps { + selectedTicketId?: string; + onTicketRemove: () => void; + mentions: MentionRef[]; + onMentionRemove: (userId: string) => void; + attachments: MediaItem[]; + onAttachmentRemove: (mediaId: string) => void; +} + +/** Ticket / mention / attachment chips selected in the composer so far. */ +export function ComposerChips({ + selectedTicketId, + onTicketRemove, + mentions, + onMentionRemove, + attachments, + onAttachmentRemove, +}: ComposerChipsProps) { + const hasChips = selectedTicketId || mentions.length > 0 || attachments.length > 0; + if (!hasChips) return null; + + return ( + <> + {selectedTicketId && ( +
+ + + + Ticket #{selectedTicketId} + +
+ )} + + {mentions.length > 0 && ( +
+ {mentions.map((m) => ( +
+ @{m.name} + +
+ ))} +
+ )} + + {attachments.length > 0 && ( +
+ {attachments.map((media) => ( +
+ {media.filename} + +
+ ))} +
+ )} + + ); +} diff --git a/src/features/huddle/HuddleComposer.tsx b/src/features/huddle/HuddleComposer.tsx index 0a582936..de7077ae 100644 --- a/src/features/huddle/HuddleComposer.tsx +++ b/src/features/huddle/HuddleComposer.tsx @@ -14,16 +14,11 @@ import { useEffect, useRef, useState } from 'react'; import { useTeam } from '@lib/TeamContext'; import { attachmentApi } from '@lib/api'; -import { TicketPicker } from './TicketPicker'; -import { AttachmentBar } from './AttachmentBar'; -import { PulseAttachButton } from './PulseAttachButton'; import { MarkdownEditor } from './MarkdownEditor'; -import { MentionMenu } from './MentionMenu'; +import { ComposerAttachButtons, ComposerChips, type MentionRef } from './ComposerAttachments'; import { huddlePostCollab } from './collab'; import type { ComposerContent, MediaItem } from './types'; -type MentionRef = { userId: string; name: string }; - // ─── Types ──────────────────────────────────────────────────────────────────── interface HuddleComposerProps { onPost: (content: ComposerContent) => void; @@ -258,88 +253,19 @@ export function HuddleComposer({ placeholder="What's on your mind?" /> - {/* ── Ticket chip ── */} - {selectedTicketId && ( -
- - - - Ticket #{selectedTicketId} - -
- )} - - {/* ── Mention chips ── */} - {mentions.length > 0 && ( -
- {mentions.map((m) => ( -
- @{m.name} - -
- ))} -
- )} + {/* ── Ticket / mention / attachment chips ── */} + setSelectedTicketId(undefined)} + mentions={mentions} + onMentionRemove={handleMentionRemove} + attachments={attachments} + onAttachmentRemove={handleAttachmentRemove} + /> - {/* ── Attachments ── */} - {(attachments.length > 0 || ticketVideos.length > 0) && ( + {/* ── Ticket videos (auto-attached from the selected ticket) ── */} + {ticketVideos.length > 0 && (
- {attachments.map((media) => ( -
- {media.filename} - -
- ))} {ticketVideos.map((video) => (
- - - {selectedTeamId && ( - - )} - {selectedTeamId && ( - - )} + } @@ -394,6 +390,9 @@ const FilterDropdown: React.FC = ({ children, }) => { const [open, setOpen] = React.useState(false); + const triggerRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuStyle, setMenuStyle] = React.useState({}); // On narrow/native screens the filter bar wraps, so filters that prefer // bottom-end (right-aligned) can end up on the left side of the screen. @@ -417,32 +416,100 @@ const FilterDropdown: React.FC = ({ [onOpenChange], ); + // The menu is portaled to and positioned with `fixed` coordinates + // computed from the trigger's own rect β€” this lets it escape the mobile + // filter-chip row's `overflow-x-auto`, which (per the CSS overflow spec) + // also clips the *vertical* axis once any non-"visible" overflow is set, + // silently cutting off an absolutely-positioned menu docked below it. + const updatePosition = React.useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + const gutter = 8; + if (effectivePlacement === 'bottom-end') { + setMenuStyle({ + position: 'fixed', + top: rect.bottom + 8, + right: Math.max(gutter, window.innerWidth - rect.right), + left: 'auto', + }); + } else { + setMenuStyle({ + position: 'fixed', + top: rect.bottom + 8, + left: Math.max(gutter, rect.left), + right: 'auto', + }); + } + }, [effectivePlacement]); + + React.useEffect(() => { + if (!open) return; + updatePosition(); + window.addEventListener('resize', updatePosition); + window.addEventListener('scroll', updatePosition, true); + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [open, updatePosition]); + + // Close on outside click / Escape β€” the library's Dropdown handles this + // internally, but we're no longer using it for the menu itself since it + // needs to live in a portal. + React.useEffect(() => { + if (!open) return; + const handlePointerDown = (e: MouseEvent) => { + const target = e.target as Node; + if (triggerRef.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + handleOpenChange(false); + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') handleOpenChange(false); + }; + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [open, handleOpenChange]); + return ( - - {activeLabel ? `${label}: ${activeLabel}` : label} - - - } - placement={effectivePlacement} - className="z-[9999] max-w-[calc(100vw-1rem)] bg-white dark:bg-neutral-800" - > - {/* Clicking any item bubbles up to this div and closes the dropdown */} -
handleOpenChange(false)}> - - {children} - -
-
+ <> + + {open && + createPortal( +
handleOpenChange(false)} + > + + {children} + +
, + document.body, + )} + ); }; diff --git a/src/styles.css b/src/styles.css index 7f887304..9b61cb4d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -102,6 +102,27 @@ html[data-theme='dark'] { color: var(--color-neutral-500, #737373); } +/* All modals: centered popup on every screen size (override the library's + * full-screen-on-mobile defaults: `min-h-dvh`, `rounded-none`, `max-h-dvh`). + * The org-switcher bottom-sheet overrides below keep higher specificity and + * continue to win inside the 767px media query. */ +[data-slot='modal'] { + min-height: 0 !important; + border-radius: 0.75rem !important; + max-height: calc(100dvh - 2rem) !important; +} + +/* On small screens give regular modals horizontal breathing room so they + * don't touch the viewport edges (the library's centering container uses + * `p-0` on mobile). The org-switcher is full-width by intent, so exclude it. */ +@media (max-width: 639px) { + [data-slot='modal']:not(.org-switcher-modal) { + margin-left: 1rem; + margin-right: 1rem; + width: calc(100% - 2rem); + } +} + /* Prevent iOS Safari from auto-zooming when focusing inputs smaller than 16px */ @media (max-width: 767px) { input, @@ -117,37 +138,25 @@ html[data-theme='dark'] { padding-top: 0.625rem !important; padding-bottom: 0.625rem !important; } +} - /* Convert the org/team switcher's @mieweb/ui Modal to a bottom-sheet on - * mobile β€” this is the only Modal in the app that opts into the treatment - * (via the `org-switcher-modal` className on its ), every other - * Modal keeps the library's default centered dialog: - * - docked to the bottom, full-width, rounded top only, capped at 88dvh - * - internal scroll (Modal's own overflow rules keep body scrollable) - * - a quiet fade + 4px settle (matches the redesign prototype's Modal β€” - * it has no real slide distance either, just `fadeIn`) replaces the - * library's default center zoom-in, which read as a "jump" once the - * panel was already docked to the bottom edge. - * Targets the Modal content element via its `data-slot="modal"` marker - * (added by @mieweb/ui Modal). `align-self:end` pushes it to the bottom of - * the library's centering flex container without forking the component. - */ - [data-slot='modal'].org-switcher-modal { - align-self: end !important; - min-height: auto !important; - max-height: 88dvh !important; - width: 100% !important; - border-radius: 1rem 1rem 0 0 !important; - /* Kill the library's zoom-in animation utilities and use the prototype's - fade + 4px settle instead. */ - animation: none !important; - } - [data-slot='modal'].org-switcher-modal[data-state='open'] { - animation: mieweb-modal-fade-in 150ms ease-out both !important; - } - [data-slot='modal'].org-switcher-modal[data-state='closed'] { - animation: mieweb-modal-fade-out 120ms ease-in both !important; - } +/* Org/team switcher Modal β€” always a bottom sheet on all screen sizes. + * `align-self:end` pushes it to the bottom of the library's centering flex + * container without forking the Modal component. + */ +[data-slot='modal'].org-switcher-modal { + align-self: end !important; + min-height: auto !important; + max-height: 88dvh !important; + width: 100% !important; + border-radius: 1rem 1rem 0 0 !important; + animation: none !important; +} +[data-slot='modal'].org-switcher-modal[data-state='open'] { + animation: mieweb-modal-fade-in 150ms ease-out both !important; +} +[data-slot='modal'].org-switcher-modal[data-state='closed'] { + animation: mieweb-modal-fade-out 120ms ease-in both !important; } @keyframes mieweb-modal-fade-in { From 2b31c1afbcb96bf0adab2643f5a6c02c05373653 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 3 Aug 2026 17:47:37 -0400 Subject: [PATCH 26/66] Huddle: flatten composer chrome, widen feed column Drop the avatar circle and nested flex wrapper from the expanded composer, trim its padding, and widen the feed's max-width from 2xl to 4xl so it isn't stranded in a narrow strip on wide dashboards. --- src/features/huddle/HuddleComposer.tsx | 155 +++++++++++-------------- src/pages/Huddle.tsx | 2 +- 2 files changed, 70 insertions(+), 87 deletions(-) diff --git a/src/features/huddle/HuddleComposer.tsx b/src/features/huddle/HuddleComposer.tsx index de7077ae..b5863122 100644 --- a/src/features/huddle/HuddleComposer.tsx +++ b/src/features/huddle/HuddleComposer.tsx @@ -215,7 +215,7 @@ export function HuddleComposer({ if (!expanded) { return (
setExpanded(true)} >
-
-
- {userInitials} -
- -
- {/* ── Rich editor (Kerebron β€” markdown in/out, working toolbar) ── */} - + {/* ── Rich editor (Kerebron β€” markdown in/out, working toolbar) ── */} + - {/* ── Ticket / mention / attachment chips ── */} - setSelectedTicketId(undefined)} - mentions={mentions} - onMentionRemove={handleMentionRemove} - attachments={attachments} - onAttachmentRemove={handleAttachmentRemove} - /> + {/* ── Ticket / mention / attachment chips ── */} + setSelectedTicketId(undefined)} + mentions={mentions} + onMentionRemove={handleMentionRemove} + attachments={attachments} + onAttachmentRemove={handleAttachmentRemove} + /> - {/* ── Ticket videos (auto-attached from the selected ticket) ── */} - {ticketVideos.length > 0 && ( -
- {ticketVideos.map((video) => ( -
- - - - {video.filename} - - (from ticket) - -
- ))} -
- )} - - {/* ── Button bar ── */} -
- - - - βŒ˜β†΅ to post - - -
+ + + + {video.filename} + (from ticket) +
+ ))}
+ )} + + {/* ── Button bar ── */} +
+ + + + βŒ˜β†΅ to post + +
); diff --git a/src/pages/Huddle.tsx b/src/pages/Huddle.tsx index fb4e5df4..8834d81e 100644 --- a/src/pages/Huddle.tsx +++ b/src/pages/Huddle.tsx @@ -191,7 +191,7 @@ export default function Huddle() { return ( -
+
{/* Feed / Drafts tabs + actions */}
From bcef77087a7087cabf2df7eb5b9ab37bf07efc63 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 3 Aug 2026 17:51:38 -0400 Subject: [PATCH 27/66] Add share button to team header with compact icon-button strip Replace text "Copy" link with a tight code badge + copy + share icon button group so the invite code row stays uncluttered. Share uses the Web Share API with a clipboard fallback for desktop. --- src/features/teams/TeamsPage.tsx | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/features/teams/TeamsPage.tsx b/src/features/teams/TeamsPage.tsx index a17d6ada..c7aeecf1 100644 --- a/src/features/teams/TeamsPage.tsx +++ b/src/features/teams/TeamsPage.tsx @@ -21,6 +21,7 @@ import { faPlus, faQrcode, faRightToBracket, + faShareNodes, faShield, faTrash, faUserMinus, @@ -562,13 +563,29 @@ export const TeamsPage: React.FC = () => { )} {!selectedTeam.isPersonal && ( -
- +
+ {selectedTeam.code} - +
)} From 421d20c729351b05896fcc3317c9da8f331fb1f0 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 3 Aug 2026 17:53:26 -0400 Subject: [PATCH 28/66] Clock page: widen column, stop dimming the bottom-nav FAB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match Clock's column width to Huddle's (max-w-2xl -> max-w-4xl). Also drop the opacity/saturate dimming on the bottom-nav Clock In/Out FAB when a plan or wrap-up is pending β€” it's a plain link to the clock page, not a disabled control, so it shouldn't look blocked. --- src/features/clock/ClockPage.tsx | 2 +- src/ui/BottomNav.tsx | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/src/features/clock/ClockPage.tsx b/src/features/clock/ClockPage.tsx index bfdb8373..dca5bb69 100644 --- a/src/features/clock/ClockPage.tsx +++ b/src/features/clock/ClockPage.tsx @@ -369,7 +369,7 @@ export const ClockPage: React.FC = () => { return ( -
+
{/* ── Status β€” eyebrow + big bold session timer ── */}
{ const { pathname, navigate } = useRouter(); const { isClockedIn, planGate } = useClockToggle(); - // 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. + // Plan-first gate: the FAB always navigates to the clock page (where the + // inline composer lives) in full color β€” it's a link, not a disabled + // control, so it never dims even when today's plan/wrap-up is still needed. const planBlocked = planGate.planMissing || planGate.wrapUpMissing; return ( @@ -74,10 +74,7 @@ export const BottomNav: React.FC = () => { : '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', - planBlocked ? 'opacity-50 saturate-50' : '', - ].join(' ')} + 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" style={{ background: isClockedIn ? 'linear-gradient(135deg, #f87171, #dc2626)' From 57480399ef0c32dcc020301431f3a02cfe5be2b5 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 3 Aug 2026 18:15:39 -0400 Subject: [PATCH 29/66] Tickets: fix clipped ticket options menu The per-row "..." menu used the plain library Dropdown, which positions its menu absolutely inside the ticket list's overflow-y-scroll container. Per the CSS overflow spec, once one axis is scrollable the other axis clips too, so the menu was silently cut off for rows near the bottom of the list -- the same class of bug FilterDropdown already works around elsewhere in this file. Portal the menu to document.body with fixed coordinates computed from the trigger's rect, same pattern. --- src/features/tickets/TicketsPage.tsx | 184 ++++++++++++++++++--------- 1 file changed, 123 insertions(+), 61 deletions(-) diff --git a/src/features/tickets/TicketsPage.tsx b/src/features/tickets/TicketsPage.tsx index 49ff76b1..9bde5d47 100644 --- a/src/features/tickets/TicketsPage.tsx +++ b/src/features/tickets/TicketsPage.tsx @@ -31,7 +31,6 @@ import { Button, Card, CardContent, - Dropdown, DropdownContent, DropdownItem, DropdownSeparator, @@ -48,7 +47,7 @@ import { type DropdownPlacement, } from '@mieweb/ui'; import { Capacitor } from '@capacitor/core'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { @@ -177,6 +176,9 @@ const TicketRow: React.FC = ({ }) => { const { navigate } = useRouter(); const [menuOpen, setMenuOpen] = useState(false); + const menuTriggerRef = useRef(null); + const menuRef = useRef(null); + const [menuStyle, setMenuStyle] = useState({}); const { icon, className: iconClass } = statusIconFor(ticket.status); const showStatusLabel = ticket.status && @@ -185,6 +187,56 @@ const TicketRow: React.FC = ({ ticket.status !== 'reviewed'; const statusLabel = STATUS_OPTIONS.find((s) => s.value === ticket.status)?.label; + // The options menu is portaled to and positioned with `fixed` + // coordinates computed from the trigger's own rect β€” the row list's + // `overflow-y-scroll` clips an absolutely-positioned menu the same way + // FilterDropdown's mobile chip row does (see its comment below): once one + // axis is non-"visible", the CSS overflow spec forces the other axis to + // clip too, silently hiding the menu when a row sits near the bottom of + // the scrollable list. + const updateMenuPosition = useCallback(() => { + const trigger = menuTriggerRef.current; + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + const gutter = 8; + setMenuStyle({ + position: 'fixed', + top: rect.bottom + 8, + right: Math.max(gutter, window.innerWidth - rect.right), + left: 'auto', + }); + }, []); + + useEffect(() => { + if (!menuOpen) return; + updateMenuPosition(); + window.addEventListener('resize', updateMenuPosition); + window.addEventListener('scroll', updateMenuPosition, true); + return () => { + window.removeEventListener('resize', updateMenuPosition); + window.removeEventListener('scroll', updateMenuPosition, true); + }; + }, [menuOpen, updateMenuPosition]); + + useEffect(() => { + if (!menuOpen) return; + const handlePointerDown = (e: MouseEvent) => { + const target = e.target as Node; + if (menuTriggerRef.current?.contains(target)) return; + if (menuRef.current?.contains(target)) return; + setMenuOpen(false); + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setMenuOpen(false); + }; + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleKeyDown); + }; + }, [menuOpen]); + return (
  • = ({ )}
  • )} - - - - } - placement="bottom-end" + + {menuOpen && + createPortal( +
    - Ticket Details - - {isCreator && ( - } - onClick={() => { - setMenuOpen(false); - onEditRequest(ticket); - }} - > - Edit Ticket - - )} - } - onClick={() => { - setMenuOpen(false); - onChangeStatusRequest(ticket); - }} - > - Change Status - - } - onClick={() => { - setMenuOpen(false); - onShareWithTimeharbor(ticket, !ticket.sharedWithTimeharbor); - }} - > - {ticket.sharedWithTimeharbor ? 'Remove from TimeHarbor' : 'Send to TimeHarbor'} - - {isCreator && ( - <> - + } - variant="danger" + icon={} onClick={() => { setMenuOpen(false); - onDeleteRequest(ticket.id); + navigate(`/app/tickets/${ticket.id}`); }} > - Delete Ticket + Ticket Details - - )} - - + {isCreator && ( + } + onClick={() => { + setMenuOpen(false); + onEditRequest(ticket); + }} + > + Edit Ticket + + )} + } + onClick={() => { + setMenuOpen(false); + onChangeStatusRequest(ticket); + }} + > + Change Status + + } + onClick={() => { + setMenuOpen(false); + onShareWithTimeharbor(ticket, !ticket.sharedWithTimeharbor); + }} + > + {ticket.sharedWithTimeharbor ? 'Remove from TimeHarbor' : 'Send to TimeHarbor'} + + {isCreator && ( + <> + + } + variant="danger" + onClick={() => { + setMenuOpen(false); + onDeleteRequest(ticket.id); + }} + > + Delete Ticket + + + )} + +
    , + document.body, + )}
    ); From d504e000ee46e5e925b9ca3ee2053417a60e1475 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 3 Aug 2026 18:27:07 -0400 Subject: [PATCH 30/66] Add personal timesheet view to Me tab on Dashboard - Add meView state ('overview' | 'timesheet') to the Me tab - Add Overview / Timesheet secondary toggle under Me tab - Render AdminTimesheetPanel pre-loaded with the current user when meView is 'timesheet' - Add userToTeamMember helper to convert TimecoreUser to TeamMember shape --- src/features/dashboard/DashboardPage.tsx | 54 +++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index c8eba965..33a7728b 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -45,6 +45,7 @@ import { type Ticket, teamApi, type TeamMember, + type TimecoreUser, teamDashboardApi, type TeamMemberClockStatus, type TeamRunningTimer, @@ -61,6 +62,14 @@ import { AdminTimesheetPanel } from '../teams/AdminTimesheetPanel'; const profilePath = (member: TeamMemberClockStatus) => `/app/profile/${member.username ?? member.userId}`; +const userToTeamMember = (user: TimecoreUser): TeamMember => ({ + id: user.id, + name: user.name, + email: user.email, + username: user.username, + image: user.image ?? null, +}); + // ─── DashboardPage ──────────────────────────────────────────────────────────── export const DashboardPage: React.FC = () => { @@ -82,6 +91,9 @@ export const DashboardPage: React.FC = () => { // still just themselves on a personal team; the tab is never hidden). const [tab, setTab] = useState<'me' | 'team'>('me'); + // "Overview" vs "Timesheet" sub-view for the "Me" tab. + const [meView, setMeView] = useState<'overview' | 'timesheet'>('overview'); + // "Overview" vs "Timesheet" β€” only relevant on the "Team" tab for admins. // The admin timesheet lives here (moved from the Teams page) so Teams can // stay focused on membership/settings. @@ -256,6 +268,46 @@ export const DashboardPage: React.FC = () => {
    } > + {/* ── Me / Timesheet toggle ─────────────────────────────────────── */} + {tab === 'me' && ( +
    + + +
    + )} + + {/* ── Me Timesheet view ────────────────────────────────────────────── */} + {tab === 'me' && meView === 'timesheet' && user && selectedTeamId && ( + + )} + {/* ── Team / Timesheet toggle (admins, non-personal teams only) ───── */} {tab === 'team' && canViewTimesheet && (
    @@ -296,7 +348,7 @@ export const DashboardPage: React.FC = () => { /> )} - {(tab === 'me' || teamView === 'overview') && ( + {(tab === 'me' ? meView === 'overview' : teamView === 'overview') && ( <> {/* ── First-time welcome ──────────────────────────────────────────── */} {isFirstTime && ( From 54f611480792e763a3fe99d1156f6acd4be8de6d Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 3 Aug 2026 18:29:00 -0400 Subject: [PATCH 31/66] Add header notification bell and sidebar app info/test push AppHeader: add a bell icon beside the profile avatar that navigates to /app/notifications, matching the pattern already used in Huddle and the bottom nav. Sidebar: add a footer row showing the app version/build (real values via App.getInfo() on native/TestFlight, falling back to the web build's VITE_APP_VERSION otherwise) plus a "Test push notification" button wired to the same notifications.testPush endpoint Settings already uses. --- src/ui/AppHeader.tsx | 15 +++++++-- src/ui/Sidebar.tsx | 72 +++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/src/ui/AppHeader.tsx b/src/ui/AppHeader.tsx index 3d3b378b..8a74e596 100644 --- a/src/ui/AppHeader.tsx +++ b/src/ui/AppHeader.tsx @@ -2,11 +2,11 @@ * AppHeader β€” Sticky top bar. * * Left : hamburger (mobile), org/team switcher - * Right : clock-in timer (if active), UserDropdown + * Right : clock-in timer (if active), notifications bell, UserDropdown * * The page title lives in the body, not here β€” see ui/pageTitle.tsx. */ -import { faBars } from '@fortawesome/free-solid-svg-icons'; +import { faBars, faBell } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Button } from '@mieweb/ui'; import React from 'react'; @@ -14,10 +14,12 @@ import React from 'react'; import { useSidebar } from './AppLayout'; import { ClockInHeaderTimer } from './ClockInHeaderTimer'; import { OrgTeamSwitcher } from './OrgTeamSwitcher'; +import { useRouter } from './router'; import { UserDropdown } from './UserDropdown'; export const AppHeader: React.FC = () => { const { openMobile } = useSidebar(); + const { navigate } = useRouter(); return (
    @@ -43,6 +45,15 @@ export const AppHeader: React.FC = () => {
    {/* Clock-in timer (visible when clocked in) */} +
    diff --git a/src/ui/Sidebar.tsx b/src/ui/Sidebar.tsx index eecdc9c5..f0fa6c2d 100644 --- a/src/ui/Sidebar.tsx +++ b/src/ui/Sidebar.tsx @@ -28,11 +28,15 @@ import { faClockRotateLeft, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { App } from '@capacitor/app'; +import { Capacitor } from '@capacitor/core'; import { Button } from '@mieweb/ui'; import { AnimatePresence, motion, MotionConfig } from 'motion/react'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; +import { notificationApi } from '../lib/api'; + // Detect page reload once at module load time (before React mounts). // Only suppresses animations during the initial reload render β€” normal // expand/collapse interactions animate as usual afterwards. @@ -160,6 +164,37 @@ const SidebarContent: React.FC = ({ variant = 'rail' }) => const { pathname } = useRouter(); const expanded = variant === 'drawer' ? true : isExpanded; + // Native build/version (what TestFlight shows) via App.getInfo(). Falls + // back to the web build's version (baked in at build time) when not + // running natively, so the row always has something to show. + const [appInfo, setAppInfo] = useState<{ version: string; build: string } | null>(null); + useEffect(() => { + if (!Capacitor.isNativePlatform()) return; + App.getInfo() + .then(({ version, build }) => setAppInfo({ version, build })) + .catch(() => {}); + }, []); + const versionLabel = appInfo + ? `v${appInfo.version} (${appInfo.build})` + : `v${import.meta.env.VITE_APP_VERSION || '1.0.0'}`; + + const [testPushLoading, setTestPushLoading] = useState(false); + const handleTestPush = async () => { + setTestPushLoading(true); + try { + await notificationApi.testPush(); + window.alert( + Capacitor.isNativePlatform() + ? 'Test push sent! You should receive a notification on this device.' + : 'Test push sent! You should see a browser notification within a few seconds.', + ); + } catch (err) { + window.alert(`Failed to send test push: ${err instanceof Error ? err.message : err}`); + } finally { + setTestPushLoading(false); + } + }; + return (
    {/* Logo / brand */} @@ -227,6 +262,41 @@ const SidebarContent: React.FC = ({ variant = 'rail' }) => ))} + {/* App info + test push β€” bottom of sidebar */} +
    + {expanded && ( +

    + {versionLabel} +

    + )} + +
    + {variant === 'rail' && (
    - - )} + + Session Active + + + + {formatTimer(Math.floor((currentTime - activeClockEvent.startTime) / 1000))} elapsed + + + + )} - {/* ── Quick stats ─────────────────────────────────────────────────── */} -
    - {/* Hours today */} - - -
    - -
    -
    - - Hours today - - - {( - (tab === 'me' ? (myStatus?.todaySeconds ?? 0) : todayTotalSeconds) / 3600 - ).toFixed(1)} - - {tab === 'me' ? ( - myStatus?.isClockedIn && ( - - ↑ clocked in + {/* ── Quick stats ─────────────────────────────────────────────────── */} +
    + {/* Hours today */} + + +
    + +
    +
    + + Hours today - ) - ) : ( - <> - {membersClocked.length > 0 && ( - - ↑ {membersClocked.length} active + + {( + (tab === 'me' ? (myStatus?.todaySeconds ?? 0) : todayTotalSeconds) / 3600 + ).toFixed(1)} + + {tab === 'me' ? ( + myStatus?.isClockedIn && ( + + ↑ clocked in + + ) + ) : ( + <> + {membersClocked.length > 0 && ( + + ↑ {membersClocked.length} active + + )} + + )} +
    +
    +
    + + {/* Open tickets */} + + +
    + +
    +
    + + Open tickets + + + {String(tab === 'me' ? myOpenTickets.length : openTickets.length)} + + {tab === 'team' && unassignedOpen.length > 0 && ( + + {unassignedOpen.length} unassigned )} - - )} -
    -
    -
    - - {/* Open tickets */} - - -
    - -
    -
    - - Open tickets - - - {String(tab === 'me' ? myOpenTickets.length : openTickets.length)} - - {tab === 'team' && unassignedOpen.length > 0 && ( - - {unassignedOpen.length} unassigned - - )} -
    -
    -
    - - {/* Closed today */} - - -
    - -
    -
    - - Closed today - - - {String(tab === 'me' ? myClosedToday.length : closedToday.length)} - -
    -
    -
    - - {/* High priority */} - - -
    - -
    -
    - - High priority - - - {String( - tab === 'me' - ? myHighPriority.filter((t) => t.status !== 'closed' && t.status !== 'done') - .length - : highPriority.filter((t) => t.status !== 'closed' && t.status !== 'done') - .length, - )} - - {(tab === 'me' ? myOverdue.length : overdue.length) > 0 && ( - - {tab === 'me' ? myOverdue.length : overdue.length} overdue - - )} -
    -
    -
    -
    +
    +
    +
    - {/* ── Team members ─────────────────────────────────────────────────── */} - {tab === 'team' && ( - - - - - Team - - {loading && } - - - {sortedMembers.length === 0 ? ( -
    - - No members found - -
    - ) : ( - <> - {membersClocked.length > 0 && ( -
    - - Online Β· {membersClocked.length} + {/* Closed today */} + + +
    + +
    +
    + + Closed today + + + {String(tab === 'me' ? myClosedToday.length : closedToday.length)} + +
    +
    +
    + + {/* High priority */} + + +
    + +
    +
    + + High priority + + + {String( + tab === 'me' + ? myHighPriority.filter((t) => t.status !== 'closed' && t.status !== 'done') + .length + : highPriority.filter((t) => t.status !== 'closed' && t.status !== 'done') + .length, + )} + + {(tab === 'me' ? myOverdue.length : overdue.length) > 0 && ( + + {tab === 'me' ? myOverdue.length : overdue.length} overdue + + )} +
    +
    +
    +
    + + {/* ── Team members ─────────────────────────────────────────────────── */} + {tab === 'team' && ( + + + + + Team + + {loading && } + + + {sortedMembers.length === 0 ? ( +
    + + No members found
    - )} -
      - {sortedMembers - .filter((m) => m.isClockedIn) - .map((member) => ( - navigate(profilePath(member))} - /> - ))} -
    - {sortedMembers.some((m) => !m.isClockedIn) && ( + ) : ( <> -
    - - Offline Β· {sortedMembers.filter((m) => !m.isClockedIn).length} - -
    + {membersClocked.length > 0 && ( +
    + + Online Β· {membersClocked.length} + +
    + )}
      {sortedMembers - .filter((m) => !m.isClockedIn) + .filter((m) => m.isClockedIn) .map((member) => ( { /> ))}
    + {sortedMembers.some((m) => !m.isClockedIn) && ( + <> +
    + + Offline Β· {sortedMembers.filter((m) => !m.isClockedIn).length} + +
    +
      + {sortedMembers + .filter((m) => !m.isClockedIn) + .map((member) => ( + navigate(profilePath(member))} + /> + ))} +
    + + )} )} - - )} -
    -
    - )} - - {/* ── Active tickets (with running timers) ─────────────────────────── */} - - - - - Active tickets - {visibleRunningTimers.length > 0 && ( - - {visibleRunningTimers.length} running - - )} - - - - - {loading ? ( -
    - -
    - ) : visibleRunningTimers.length === 0 ? ( -
    - - No active timers right now - -
    - ) : ( -
      - {visibleRunningTimers.map((timer) => { - const ticket = tickets.find((t) => t.id === timer.ticketId); - const elapsedSec = Math.floor((currentTime - timer.startTime) / 1000); - const priorityColor = - ticket?.priority === 'high' || ticket?.priority === 'urgent' - ? 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400' - : ticket?.priority === 'medium' - ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400' - : 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'; - return ( -
    • - {ticket?.priority && ( - - {ticket.priority === 'urgent' - ? 'High' - : ticket.priority.charAt(0).toUpperCase() + ticket.priority.slice(1)} - - )} -
      - - {timer.ticketTitle} - -
      - - - {timer.userName} - -
      -
      -
      - - {formatTimer(elapsedSec)} - -
      -
    • - ); - })} -
    +
    +
    )} -
    -
    - - {/* ── Time logged today ────────────────────────────────────────────── */} - {tab === 'team' && memberStatuses.some((m) => m.todaySeconds > 0) && ( - - - - - Time logged today - - {(todayTotalSeconds / 3600).toFixed(1)}h total - - - - -
      - {sortedMembers - .filter((m) => m.todaySeconds > 0 || m.isClockedIn) - .map((member) => { - const barPct = Math.round((member.todaySeconds / maxMemberSeconds) * 100); - return ( -
    • - + + + {loading ? ( +
      + +
      + ) : visibleRunningTimers.length === 0 ? ( +
      + + No active timers right now + +
      + ) : ( +
        + {visibleRunningTimers.map((timer) => { + const ticket = tickets.find((t) => t.id === timer.ticketId); + const elapsedSec = Math.floor((currentTime - timer.startTime) / 1000); + const priorityColor = + ticket?.priority === 'high' || ticket?.priority === 'urgent' + ? 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-400' + : ticket?.priority === 'medium' + ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400' + : 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'; + return ( +
      • + {ticket?.priority && ( + + {ticket.priority === 'urgent' + ? 'High' + : ticket.priority.charAt(0).toUpperCase() + ticket.priority.slice(1)} + + )}
        - - {member.name.split(' ')[0]} - {member.name.split(' ')[1] ? ` ${member.name.split(' ')[1][0]}.` : ''} + + {timer.ticketTitle} -
        -
        +
        + + + {timer.userName} +
        - - - {formatDuration(member.todaySeconds)} - - {member.isClockedIn && ( - - )} -
      • - ); - })} -
      -
      - - {membersClocked.length} member{membersClocked.length !== 1 ? 's' : ''} currently - tracking - -
      -
      - - )} +
      + + {formatTimer(elapsedSec)} + +
      +
    • + ); + })} +
    + )} +
    +
    + + {/* ── Time logged today ────────────────────────────────────────────── */} + {tab === 'team' && memberStatuses.some((m) => m.todaySeconds > 0) && ( + + + + + Time logged today + + {(todayTotalSeconds / 3600).toFixed(1)}h total + + + + +
      + {sortedMembers + .filter((m) => m.todaySeconds > 0 || m.isClockedIn) + .map((member) => { + const barPct = Math.round((member.todaySeconds / maxMemberSeconds) * 100); + return ( +
    • + + + {formatDuration(member.todaySeconds)} + + {member.isClockedIn && ( + + )} +
    • + ); + })} +
    +
    + + {membersClocked.length} member{membersClocked.length !== 1 ? 's' : ''} currently + tracking + +
    +
    +
    + )} )} diff --git a/src/features/teams/TeamsPage.tsx b/src/features/teams/TeamsPage.tsx index c7aeecf1..8d27dc49 100644 --- a/src/features/teams/TeamsPage.tsx +++ b/src/features/teams/TeamsPage.tsx @@ -695,7 +695,11 @@ export const TeamsPage: React.FC = () => {
    {isMemberAdmin && ( - }> + } + > Admin )} @@ -763,7 +767,6 @@ export const TeamsPage: React.FC = () => { })}
    -
    )} @@ -976,143 +979,145 @@ export const TeamsPage: React.FC = () => { > Clock -
    -
    - - Require a plan for every clock-in/out - - - Members post a plan to start each session, and add a wrap-up to it before clocking - out β€” one Huddle post per session. - -
    - { - if (!selectedTeamId) return; - const previous = requirePlanForClock; - setRequirePlanForClock(checked); - setSavingPlanSetting(true); - setFormError(null); - try { - await teamApi.updateSettings(selectedTeamId, { requirePlanForClock: checked }); - refetchTeams(); - } catch (e: any) { - setRequirePlanForClock(previous); - setFormError(e.message || 'Failed to update setting'); - } finally { - setSavingPlanSetting(false); - } - }} - /> -
    - {formError && ( - - {formError} - - )} - - Membership - -
    -
    - - Auto-accept join requests +
    +
    + + Require a plan for every clock-in/out + + + Members post a plan to start each session, and add a wrap-up to it before + clocking out β€” one Huddle post per session. + +
    + { + if (!selectedTeamId) return; + const previous = requirePlanForClock; + setRequirePlanForClock(checked); + setSavingPlanSetting(true); + setFormError(null); + try { + await teamApi.updateSettings(selectedTeamId, { + requirePlanForClock: checked, + }); + refetchTeams(); + } catch (e: any) { + setRequirePlanForClock(previous); + setFormError(e.message || 'Failed to update setting'); + } finally { + setSavingPlanSetting(false); + } + }} + /> +
    + {formError && ( + + {formError} + + )} + + Membership - - Anyone joining with the team code is added immediately β€” no pending approval from an - admin. +
    +
    + + Auto-accept join requests + + + Anyone joining with the team code is added immediately β€” no pending approval + from an admin. + +
    + { + if (!selectedTeamId) return; + const previous = autoAcceptJoins; + setAutoAcceptJoins(checked); + setSavingAutoAccept(true); + setFormError(null); + try { + await teamApi.updateSettings(selectedTeamId, { autoAcceptJoins: checked }); + refetchTeams(); + } catch (e: any) { + setAutoAcceptJoins(previous); + setFormError(e.message || 'Failed to update setting'); + } finally { + setSavingAutoAccept(false); + } + }} + /> +
    + + Invitations -
    - { - if (!selectedTeamId) return; - const previous = autoAcceptJoins; - setAutoAcceptJoins(checked); - setSavingAutoAccept(true); - setFormError(null); - try { - await teamApi.updateSettings(selectedTeamId, { autoAcceptJoins: checked }); - refetchTeams(); - } catch (e: any) { - setAutoAcceptJoins(previous); - setFormError(e.message || 'Failed to update setting'); - } finally { - setSavingAutoAccept(false); - } - }} - /> -
    - - Invitations - - {invitationsLoading ? ( -
    - -
    - ) : ( - - - - Email - Status - Sent - Expires - Actions - - - - {invitations.map((inv) => ( - - {inv.email} - - {inv.status} - - {new Date(inv.createdAt).toLocaleDateString()} - {new Date(inv.expiresAt).toLocaleDateString()} - - {inv.status === 'pending' && ( - - )} - - - ))} - {invitations.length === 0 && ( - - - - No invitations have been sent for this team. - - - - )} - -
    - )} + {invitationsLoading ? ( +
    + +
    + ) : ( + + + + Email + Status + Sent + Expires + Actions + + + + {invitations.map((inv) => ( + + {inv.email} + + {inv.status} + + {new Date(inv.createdAt).toLocaleDateString()} + {new Date(inv.expiresAt).toLocaleDateString()} + + {inv.status === 'pending' && ( + + )} + + + ))} + {invitations.length === 0 && ( + + + + No invitations have been sent for this team. + + + + )} + +
    + )} )} diff --git a/src/ui/OrgTeamSwitcher.tsx b/src/ui/OrgTeamSwitcher.tsx index 91bff712..23c6fd74 100644 --- a/src/ui/OrgTeamSwitcher.tsx +++ b/src/ui/OrgTeamSwitcher.tsx @@ -13,14 +13,7 @@ */ import { faChevronDown, faClock } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - Badge, - Modal, - ModalHeader, - ModalBody, - Select, - Text, -} from '@mieweb/ui'; +import { Badge, Modal, ModalHeader, ModalBody, Select, Text } from '@mieweb/ui'; import React, { useCallback, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; @@ -126,119 +119,119 @@ export const OrgTeamSwitcher: React.FC = () => { Switch organization / team - {organizations.length > 0 && ( -
    - - Organization - - setSelectedOrgId(id)} + options={organizations.map((organization) => ({ + value: organization.id, + label: organization.role + ? `${organization.name} β€” ${ROLE_LABEL[organization.role]}` + : organization.name, + }))} + /> +
    + )} + + + Team + + + {teamsReady && teams.length === 0 && ( +
    + + No teams in this organization + +
    + )} + +
    + {teams.map((team) => { + const pendingCount = pendingCountByTeam.get(team.id) ?? 0; + const selected = team.id === selectedTeam?.id; + return ( + - ); - })} -
    - - {ownPendingRequests.length > 0 && ( -
    - - Awaiting approval - - {ownPendingRequests.map((req) => ( -
    - - - {req.teamCode} - - - Pending - -
    - ))} + + {pendingCount > 0 && ( + + {pendingCount} + + )} + + {team.members.length} member{team.members.length === 1 ? '' : 's'} + + + + ); + })}
    - )} - -
    -
    , - document.body, - )} + {ownPendingRequests.length > 0 && ( +
    + + Awaiting approval + + {ownPendingRequests.map((req) => ( +
    + + + {req.teamCode} + + + Pending + +
    + ))} +
    + )} + + + + , + document.body, + )}
    ); }; From cf7661d96968bb36c7b96711ecf2b589f4eb633b Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Mon, 3 Aug 2026 19:34:51 -0400 Subject: [PATCH 33/66] Huddle: match composer width to post cards on mobile The composer was wrapped in an extra px-4 inset on mobile that PostCard never had, so it rendered as a narrower floating bar next to full-bleed post rows below it. Drop the wrapper padding so both are flush edge-to-edge on mobile, matching PostCard's own sizing. --- src/pages/Huddle.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Huddle.tsx b/src/pages/Huddle.tsx index 8834d81e..e2e86afc 100644 --- a/src/pages/Huddle.tsx +++ b/src/pages/Huddle.tsx @@ -284,7 +284,7 @@ export default function Huddle() { {/* Composer stays put while the feed below it scrolls */} {selectedTeamId && feedTab === 'feed' && ( -
    +
    Date: Tue, 4 Aug 2026 14:39:11 -0400 Subject: [PATCH 34/66] Mobile nav: More sheet + reconnect/dropdown fixes - BottomNav: replace Teams tab with a bottom-sliding "More" sheet containing Teams, Organization, and Profile (mirrors the redesign prototype); remove the mobile hamburger drawer from AppHeader/Sidebar since it's no longer reachable. - ddp.ts/TeamContext: add DdpClient.onDisconnect() fired the instant the socket drops, and reset the teams subscription's ready guard on disconnect so a reconnect's transient partial team list can no longer silently reset the selected team back to Personal. - TicketsPage: clamp filter dropdown menus (Team/Priority/Status/Assignee) to the ticket list card's actual bounds after mount, fixing menus rendering partly outside the card/viewport on mobile. --- src/features/tickets/TicketsPage.tsx | 57 +++++++++++- src/lib/TeamContext.tsx | 11 +++ src/lib/ddp.ts | 14 +++ src/ui/AppHeader.tsx | 17 +--- src/ui/BottomNav.tsx | 125 +++++++++++++++++++++++++-- src/ui/Sidebar.tsx | 62 +++---------- 6 files changed, 214 insertions(+), 72 deletions(-) diff --git a/src/features/tickets/TicketsPage.tsx b/src/features/tickets/TicketsPage.tsx index 9bde5d47..81cb06b3 100644 --- a/src/features/tickets/TicketsPage.tsx +++ b/src/features/tickets/TicketsPage.tsx @@ -438,6 +438,9 @@ interface FilterDropdownProps { activeMenuId?: string | null; /** This dropdown's own id β€” used to decide whether to self-close. */ menuId?: string; + /** Container the menu must stay within (e.g. the ticket list card) β€” the + * menu is clamped to this element's bounds in addition to the viewport. */ + boundaryRef?: React.RefObject; onOpenChange?: (open: boolean) => void; children: React.ReactNode; } @@ -448,6 +451,7 @@ const FilterDropdown: React.FC = ({ placement = 'bottom-start', activeMenuId, menuId, + boundaryRef, onOpenChange, children, }) => { @@ -488,6 +492,7 @@ const FilterDropdown: React.FC = ({ if (!trigger) return; const rect = trigger.getBoundingClientRect(); const gutter = 8; + shiftAppliedRef.current = false; if (effectivePlacement === 'bottom-end') { setMenuStyle({ position: 'fixed', @@ -516,6 +521,47 @@ const FilterDropdown: React.FC = ({ }; }, [open, updatePosition]); + // `updatePosition` anchors the menu to the trigger before its actual + // (content-dependent) width is known, so a menu docked near a screen edge + // β€” e.g. "Assignee" wrapping to bottom-start on mobile β€” can still render + // partly off-screen or spill outside the ticket list card. Once mounted, + // measure the real box and nudge it back within the viewport (and the + // card, if `boundaryRef` is given). Guarded by a ref (reset each time it + // opens) so the resulting `setMenuStyle` call doesn't re-trigger itself. + const shiftAppliedRef = React.useRef(false); + React.useEffect(() => { + if (!open) shiftAppliedRef.current = false; + }, [open]); + + React.useLayoutEffect(() => { + if (!open || shiftAppliedRef.current) return; + const menu = menuRef.current; + if (!menu) return; + shiftAppliedRef.current = true; + const rect = menu.getBoundingClientRect(); + const gutter = 8; + const boundaryRect = boundaryRef?.current?.getBoundingClientRect(); + const maxRight = boundaryRect + ? Math.min(window.innerWidth - gutter, boundaryRect.right - gutter) + : window.innerWidth - gutter; + const minLeft = boundaryRect ? Math.max(gutter, boundaryRect.left + gutter) : gutter; + const overflowRight = rect.right - maxRight; + const overflowLeft = minLeft - rect.left; + if (overflowRight > 0) { + setMenuStyle((prev) => + typeof prev.left === 'number' + ? { ...prev, left: Math.max(minLeft, prev.left - overflowRight) } + : typeof prev.right === 'number' + ? { ...prev, right: Math.max(gutter, prev.right + overflowRight) } + : prev, + ); + } else if (overflowLeft > 0) { + setMenuStyle((prev) => + typeof prev.left === 'number' ? { ...prev, left: prev.left + overflowLeft } : prev, + ); + } + }, [open, menuStyle, boundaryRef]); + // Close on outside click / Escape β€” the library's Dropdown handles this // internally, but we're no longer using it for the menu itself since it // needs to live in a portal. @@ -950,6 +996,7 @@ export const TicketsPage: React.FC = () => { }, [membersByTeam, selectedTeamId, teams]); // Active filter label helpers + const ticketCardRef = React.useRef(null); const activeTeamLabel = useMemo( () => (teamFilter ? (teams.find((t: Team) => t.id === teamFilter)?.name ?? null) : null), [teamFilter, teams], @@ -1288,7 +1335,11 @@ export const TicketsPage: React.FC = () => { )} {/* ── Unified ticket list (GitHub style) ── */} - + {/* GitHub-style header: Open / Closed tabs + filter dropdowns */}
    { setOpenFilterMenu(open ? 'team' : null)} @@ -1358,6 +1410,7 @@ export const TicketsPage: React.FC = () => { setOpenFilterMenu(open ? 'priority' : null)} @@ -1387,6 +1440,7 @@ export const TicketsPage: React.FC = () => { label="Status" activeLabel={activeStatusDetailLabel} placement="bottom-end" + boundaryRef={ticketCardRef} menuId="status" activeMenuId={openFilterMenu} onOpenChange={(open) => setOpenFilterMenu(open ? 'status' : null)} @@ -1418,6 +1472,7 @@ export const TicketsPage: React.FC = () => { label="Assignee" activeLabel={activeAssigneeLabel} placement="bottom-end" + boundaryRef={ticketCardRef} menuId="assignee" activeMenuId={openFilterMenu} onOpenChange={(open) => setOpenFilterMenu(open ? 'assignee' : null)} diff --git a/src/lib/TeamContext.tsx b/src/lib/TeamContext.tsx index 8cd6958c..35c59844 100644 --- a/src/lib/TeamContext.tsx +++ b/src/lib/TeamContext.tsx @@ -209,6 +209,13 @@ export const TeamProvider: React.FC<{ children: React.ReactNode }> = ({ children // and the "pick first available" effects below would treat the still-absent // selected team as gone and silently reset the selection. Hold updates // until the subscription signals ready, then track changes live. + // + // The same race happens again after a reconnect (e.g. an idle websocket + // timing out): the client drops all cached docs and re-streams them one + // by one, so `subReady` must be reset the instant the socket drops and + // only flip back on once the re-subscription's `ready` arrives β€” otherwise + // a stray partial list (often just the personal team) briefly overwrites + // `teams` and silently switches the user's selection back to Personal. let subReady = false; const applyLiveDocs = () => { @@ -222,6 +229,9 @@ export const TeamProvider: React.FC<{ children: React.ReactNode }> = ({ children ); }; + const offDisconnect = ddp.onDisconnect(() => { + subReady = false; + }); const offChange = ddp.onCollectionChange('teams', applyLiveDocs); const unsubscribe = ddp.subscribe('teams.byUser', [], () => { subReady = true; @@ -230,6 +240,7 @@ export const TeamProvider: React.FC<{ children: React.ReactNode }> = ({ children }); return () => { + offDisconnect(); offChange(); unsubscribe(); }; diff --git a/src/lib/ddp.ts b/src/lib/ddp.ts index de9b8967..812c3eda 100644 --- a/src/lib/ddp.ts +++ b/src/lib/ddp.ts @@ -77,6 +77,7 @@ class DdpClient { private backgroundAttempt = 0; private backgroundTimer: ReturnType | null = null; private reconnectListeners = new Set(); + private disconnectListeners = new Set(); status: 'idle' | 'connecting' | 'connected' | 'failed' = 'idle'; /** @@ -89,6 +90,18 @@ class DdpClient { return () => this.reconnectListeners.delete(fn); } + /** + * Notified the moment a previously-connected socket drops, before any + * reconnect/resubscribe attempt starts. Lets callers that hold their own + * "subscription is ready" flag reset it, so the transient empty/partial + * collection state produced while docs re-stream in after a reconnect + * can't be mistaken for a real, complete update. Returns an unsubscribe fn. + */ + public onDisconnect(fn: Listener): () => void { + this.disconnectListeners.add(fn); + return () => this.disconnectListeners.delete(fn); + } + /** Connect (once) and authenticate the connection via auth.bridge. */ public ensureConnected(): Promise { if (!this.connectPromise) { @@ -213,6 +226,7 @@ class DdpClient { this.connectPromise = null; this.authPromise = null; this.ws = null; + for (const fn of this.disconnectListeners) fn(); if (this.activeSubs.size === 0 || this.reconnectTimer) return; const delay = Math.min(30_000, 1000 * 2 ** this.reconnectAttempt++); diff --git a/src/ui/AppHeader.tsx b/src/ui/AppHeader.tsx index 8a74e596..cf8ba4bd 100644 --- a/src/ui/AppHeader.tsx +++ b/src/ui/AppHeader.tsx @@ -1,24 +1,22 @@ /** * AppHeader β€” Sticky top bar. * - * Left : hamburger (mobile), org/team switcher + * Left : org/team switcher * Right : clock-in timer (if active), notifications bell, UserDropdown * * The page title lives in the body, not here β€” see ui/pageTitle.tsx. */ -import { faBars, faBell } from '@fortawesome/free-solid-svg-icons'; +import { faBell } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Button } from '@mieweb/ui'; import React from 'react'; -import { useSidebar } from './AppLayout'; import { ClockInHeaderTimer } from './ClockInHeaderTimer'; import { OrgTeamSwitcher } from './OrgTeamSwitcher'; import { useRouter } from './router'; import { UserDropdown } from './UserDropdown'; export const AppHeader: React.FC = () => { - const { openMobile } = useSidebar(); const { navigate } = useRouter(); return ( @@ -26,17 +24,6 @@ export const AppHeader: React.FC = () => {
    {/* ── Left ── */}
    - {/* Mobile hamburger */} - - {/* Current org/team scope */}
    diff --git a/src/ui/BottomNav.tsx b/src/ui/BottomNav.tsx index 1c41bef8..29e1614c 100644 --- a/src/ui/BottomNav.tsx +++ b/src/ui/BottomNav.tsx @@ -2,26 +2,41 @@ * BottomNav β€” Mobile-only bottom navigation bar. * * Visible only on small screens (md:hidden). - * Five tabs: Dashboard, Huddle, Clock In/Out (center FAB), Tickets, Teams. - * Settings is available via the profile/avatar dropdown in the header. + * Five tabs: Dashboard, Huddle, Clock In/Out (center FAB), Tickets, More. + * "More" opens a sheet with the remaining sidebar destinations (Teams, + * Organization, Timesheet, Work, Media Library, Messages, Notifications, + * Activity Log, Profile, Settings) so every sidebar link stays reachable + * on mobile without a hamburger drawer. * 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 { + faBell, faCircleStop, + faCircleUser, faClock, + faClockRotateLeft, faComments, + faEllipsis, + faEnvelope, faGauge, + faGear, faListCheck, + faPhotoFilm, + faSitemap, + faStopwatch, + faTable, faUsers, + faXmark, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { motion, MotionConfig } from 'motion/react'; -import React from 'react'; +import { AnimatePresence, motion, MotionConfig } from 'motion/react'; +import React, { useState } from 'react'; import { useClockToggle } from '../lib/useClockToggle'; +import { useSession } from '../lib/useSession'; import { useRouter } from './router'; interface NavTab { @@ -29,6 +44,7 @@ interface NavTab { label: string; href: string; isFab?: boolean; + isMore?: boolean; } const TABS: NavTab[] = [ @@ -36,12 +52,57 @@ const TABS: NavTab[] = [ { icon: faComments, label: 'Huddle', href: '/app/huddle' }, { icon: faClock, label: 'Clock In', href: '/app/clock', isFab: true }, { icon: faListCheck, label: 'Tickets', href: '/app/tickets' }, + { icon: faEllipsis, label: 'More', href: '', isMore: true }, +]; + +interface MoreItem { + icon: typeof faGauge; + label: string; + href: string; +} + +const MORE_ITEMS: MoreItem[] = [ { icon: faUsers, label: 'Teams', href: '/app/teams' }, + { icon: faSitemap, label: 'Organization', href: '/app/organization' }, + { icon: faCircleUser, label: 'Profile', href: '/app/settings' }, + { icon: faTable, label: 'Timesheet', href: '/app/timesheet' }, + { icon: faStopwatch, label: 'Work', href: '/app/work' }, + { icon: faPhotoFilm, label: 'Media Library', href: '/app/media' }, + { icon: faEnvelope, label: 'Messages', href: '/app/messages' }, + { icon: faBell, label: 'Notifications', href: '/app/notifications' }, + { icon: faClockRotateLeft, label: 'Activity Log', href: '/app/activity' }, + { icon: faGear, label: 'Settings', href: '/app/settings' }, ]; export const BottomNav: React.FC = () => { const { pathname, navigate } = useRouter(); const { isClockedIn, planGate } = useClockToggle(); + const { user } = useSession(); + const [moreOpen, setMoreOpen] = useState(false); + + React.useEffect(() => { + if (!moreOpen) return undefined; + const original = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setMoreOpen(false); + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.body.style.overflow = original; + document.removeEventListener('keydown', onKeyDown); + }; + }, [moreOpen]); + + const openMore = () => setMoreOpen(true); + const goToMoreItem = (href: string) => { + setMoreOpen(false); + navigate(href); + }; + const goToProfile = () => { + setMoreOpen(false); + navigate(user?.username ? `/app/profile/${user.username}` : '/app/settings'); + }; // Plan-first gate: the FAB always navigates to the clock page (where the // inline composer lives) in full color β€” it's a link, not a disabled @@ -97,11 +158,12 @@ export const BottomNav: React.FC = () => { return ( +
    +
    +
    + {MORE_ITEMS.map((item) => ( + + ))} +
    +
    + +
    + )} + ); }; diff --git a/src/ui/Sidebar.tsx b/src/ui/Sidebar.tsx index f0fa6c2d..27a87b69 100644 --- a/src/ui/Sidebar.tsx +++ b/src/ui/Sidebar.tsx @@ -5,9 +5,8 @@ * 64 px (collapsed / icon-only) using a spring. Collapse control lives in the * rail footer. * - * Mobile drawer (< md): slides in from the left when isMobileOpen is true. - * The drawer always shows icons with labels (never icon-only) and does not - * show the rail collapse control β€” the hamburger/drawer is the primary pattern. + * On mobile (< md) the rail is hidden entirely β€” every sidebar destination is + * reachable via the bottom nav's More sheet instead of a hamburger drawer. * * Labels fade in/out with AnimatePresence on the rail so they never clip during resize. */ @@ -30,10 +29,8 @@ import { import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { App } from '@capacitor/app'; import { Capacitor } from '@capacitor/core'; -import { Button } from '@mieweb/ui'; import { AnimatePresence, motion, MotionConfig } from 'motion/react'; import React, { useEffect, useState } from 'react'; -import { createPortal } from 'react-dom'; import { notificationApi } from '../lib/api'; @@ -106,17 +103,14 @@ const NavLink: React.FC<{ item: NavItem; active: boolean; expanded: boolean }> = expanded, }) => { const { navigate } = useRouter(); - const { closeMobile } = useSidebar(); return ( - - - )} - , - document.body, - )} - + {/* Desktop: animated-width panel. The mobile drawer was removed β€” all + sidebar destinations are reachable via the bottom nav's More sheet. */} + + + ); }; From fb60124787a7dd43944fcc743ff186b5db6d0d22 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Tue, 4 Aug 2026 14:52:21 -0400 Subject: [PATCH 35/66] Move Admin/Developers/Help into mobile More sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UserDropdown: hide Admin, Developers, and Help sections on mobile widths (hidden md:contents) β€” only Profile, Settings, and Sign out remain in the top account menu on mobile. BottomNav: More sheet gains drill-down Admin (Enterprise, Members), Developers (Seeder), and Help (Report an Issue, Share Your Feedback, TestFlight) cards with a back button, mirroring the desktop account menu. --- src/ui/BottomNav.tsx | 169 ++++++++++++++++++++++++++++++++++++---- src/ui/UserDropdown.tsx | 61 ++++++++------- 2 files changed, 184 insertions(+), 46 deletions(-) diff --git a/src/ui/BottomNav.tsx b/src/ui/BottomNav.tsx index 29e1614c..fc134efc 100644 --- a/src/ui/BottomNav.tsx +++ b/src/ui/BottomNav.tsx @@ -14,6 +14,9 @@ */ import { faBell, + faBug, + faBuilding, + faChevronLeft, faCircleStop, faCircleUser, faClock, @@ -23,22 +26,31 @@ import { faEnvelope, faGauge, faGear, + faCircleQuestion, faListCheck, faPhotoFilm, faSitemap, faStopwatch, faTable, faUsers, + faWrench, faXmark, } from '@fortawesome/free-solid-svg-icons'; +import { faApple } from '@fortawesome/free-brands-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { AnimatePresence, motion, MotionConfig } from 'motion/react'; import React, { useState } from 'react'; +import { hasDefaultOrganizationAdminAccess } from '../lib/organizationAccess'; +import { useTeam } from '../lib/TeamContext'; import { useClockToggle } from '../lib/useClockToggle'; import { useSession } from '../lib/useSession'; +import { useAppFeedback } from './AppLayout'; import { useRouter } from './router'; +// Update this URL once the TestFlight build is published in App Store Connect. +const TESTFLIGHT_URL = 'https://testflight.apple.com/join/45w2knYf'; + interface NavTab { icon: typeof faGauge; label: string; @@ -74,11 +86,25 @@ const MORE_ITEMS: MoreItem[] = [ { icon: faGear, label: 'Settings', href: '/app/settings' }, ]; +/** Sub-sections of the More sheet β€” grouped destinations from the desktop + * account menu (Admin/Developers/Help) that drill down into a row list + * instead of navigating away immediately. */ +type MoreSection = 'root' | 'admin' | 'developers' | 'help'; + +interface MoreRow { + icon: typeof faGauge; + label: string; + onClick: () => void; +} + export const BottomNav: React.FC = () => { const { pathname, navigate } = useRouter(); const { isClockedIn, planGate } = useClockToggle(); const { user } = useSession(); + const { enterprises } = useTeam(); + const { openFeedback, openReportIssue } = useAppFeedback(); const [moreOpen, setMoreOpen] = useState(false); + const [moreSection, setMoreSection] = useState('root'); React.useEffect(() => { if (!moreOpen) return undefined; @@ -94,16 +120,69 @@ export const BottomNav: React.FC = () => { }; }, [moreOpen]); - const openMore = () => setMoreOpen(true); - const goToMoreItem = (href: string) => { + const openMore = () => { + setMoreSection('root'); + setMoreOpen(true); + }; + const closeMore = () => { setMoreOpen(false); + setMoreSection('root'); + }; + const goToMoreItem = (href: string) => { + closeMore(); navigate(href); }; const goToProfile = () => { - setMoreOpen(false); + closeMore(); navigate(user?.username ? `/app/profile/${user.username}` : '/app/settings'); }; + const showAdmin = hasDefaultOrganizationAdminAccess(user) || enterprises.length > 0; + const showDevelopers = import.meta.env.MODE !== 'production'; + + const adminRows: MoreRow[] = [ + ...(enterprises.length > 0 + ? [{ icon: faBuilding, label: 'Enterprise', onClick: () => goToMoreItem('/app/enterprise') }] + : []), + { icon: faUsers, label: 'Members', onClick: () => goToMoreItem('/app/org/members') }, + ]; + const developerRows: MoreRow[] = [ + { icon: faWrench, label: 'Seeder', onClick: () => goToMoreItem('/app/seeder') }, + ]; + const helpRows: MoreRow[] = [ + { + icon: faBug, + label: 'Report an Issue', + onClick: () => { + closeMore(); + openReportIssue(); + }, + }, + { + icon: faComments, + label: 'Share Your Feedback', + onClick: () => { + closeMore(); + openFeedback(); + }, + }, + { + icon: faApple, + label: 'TestFlight', + onClick: () => { + closeMore(); + window.open(TESTFLIGHT_URL, '_blank', 'noopener,noreferrer'); + }, + }, + ]; + + const SECTION_CONFIG: Record, { label: string; rows: MoreRow[] }> = + { + admin: { label: 'Admin', rows: adminRows }, + developers: { label: 'Developers', rows: developerRows }, + help: { label: 'Help', rows: helpRows }, + }; + // Plan-first gate: the FAB always navigates to the clock page (where the // inline composer lives) in full color β€” it's a link, not a disabled // control, so it never dims even when today's plan/wrap-up is still needed. @@ -198,7 +277,7 @@ export const BottomNav: React.FC = () => { exit={{ opacity: 0 }} transition={{ duration: 0.15 }} className="absolute inset-0 bg-black/40" - onClick={() => setMoreOpen(false)} + onClick={closeMore} /> { transition={{ type: 'spring', damping: 30, stiffness: 300 }} className="absolute inset-x-0 bottom-0 flex max-h-[88vh] flex-col rounded-t-2xl bg-white pb-[env(safe-area-inset-bottom)] shadow-xl dark:bg-neutral-900" > -
    -

    More

    +
    + {moreSection !== 'root' && ( + + )} +

    + {moreSection === 'root' ? 'More' : SECTION_CONFIG[moreSection].label} +

    -
    - {MORE_ITEMS.map((item) => ( + {moreSection === 'root' ? ( +
    + {MORE_ITEMS.map((item) => ( + + ))} + {showAdmin && ( + + )} + {showDevelopers && ( + + )} - ))} -
    +
    + ) : ( +
    + {SECTION_CONFIG[moreSection].rows.map((row) => ( + + ))} +
    + )}
    diff --git a/src/ui/UserDropdown.tsx b/src/ui/UserDropdown.tsx index 1b53342d..ee172740 100644 --- a/src/ui/UserDropdown.tsx +++ b/src/ui/UserDropdown.tsx @@ -136,51 +136,54 @@ export const UserDropdown: React.FC = () => { {(showOrganizationAdmin || enterprises.length > 0) && ( - <> +
    Admin - - )} - {enterprises.length > 0 && ( - } onClick={handleEnterprisePage}> - Enterprise - - )} - - {(showOrganizationAdmin || enterprises.length > 0) && ( - } - onClick={handleOrganizationMembers} - > - Members - + {enterprises.length > 0 && ( + } + onClick={handleEnterprisePage} + > + Enterprise + + )} + + } + onClick={handleOrganizationMembers} + > + Members + +
    )} {import.meta.env.MODE !== 'production' && ( - <> +
    Developers } onClick={handleSeeder}> Seeder - +
    )} - - Help +
    + + Help - } onClick={handleReportIssue}> - Report an Issue - + } onClick={handleReportIssue}> + Report an Issue + - } onClick={handleFeedback}> - Share Your Feedback - + } onClick={handleFeedback}> + Share Your Feedback + - } onClick={handleTestFlight}> - TestFlight - + } onClick={handleTestFlight}> + TestFlight + +
    From 0a1f2bd378dd847079a9445488ed2cf3ab796c1c Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Tue, 4 Aug 2026 14:58:29 -0400 Subject: [PATCH 36/66] Persist dashboard Me/Team tab + add Recent Activity feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashboardPage: persist the Me/Team toggle to localStorage (app:dashboardTab) so navigating away and back no longer silently resets it to Me. DashboardPage: add a Recent Activity card showing everyone's published plan/wrap-up huddle posts for the team (live via the huddlePosts.byTeam DDP subscription), each row linking to /app/huddle?postId=. Huddle page: support the postId deep link β€” scrolls to and briefly highlights the matching post once the feed loads, then strips the query param. PostCard: accept a highlighted prop (ring highlight) and a stable id for scroll-to-post targeting. --- src/features/dashboard/DashboardPage.tsx | 110 ++++++++++++++++++++++- src/features/huddle/PostCard/index.tsx | 9 +- src/pages/Huddle.tsx | 29 ++++++ 3 files changed, 146 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index 3fdb6ae0..faca23c7 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -23,6 +23,7 @@ import { faArrowRight, faPlus, faRightToBracket, + faComments, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { @@ -49,10 +50,12 @@ import { teamDashboardApi, type TeamMemberClockStatus, type TeamRunningTimer, + type HuddlePost, } from '../../lib/api'; import { useSession } from '../../lib/useSession'; import { useTeam } from '../../lib/TeamContext'; import { useRefresh } from '../../lib/RefreshContext'; +import { getDdpClient } from '../../lib/ddp'; import { formatDuration, formatTimer } from '../../lib/timeUtils'; import { useRouter } from '../../ui/router'; import { AppPage } from '../../ui/AppPage'; @@ -96,7 +99,16 @@ export const DashboardPage: React.FC = () => { // "Me" is the default β€” a personal-team user sees their own numbers // immediately, and can switch to "Team" to see everyone (even if that's // still just themselves on a personal team; the tab is never hidden). - const [tab, setTab] = useState<'me' | 'team'>('me'); + // Persisted so navigating away and back to the dashboard doesn't silently + // reset the user back to "Me" after they've chosen "Team". + const [tab, _setTab] = useState<'me' | 'team'>(() => { + if (typeof window === 'undefined') return 'me'; + return localStorage.getItem('app:dashboardTab') === 'team' ? 'team' : 'me'; + }); + const setTab = useCallback((next: 'me' | 'team') => { + _setTab(next); + if (typeof window !== 'undefined') localStorage.setItem('app:dashboardTab', next); + }, []); // "Overview" vs "Timesheet" sub-view for the "Me" tab. const [meView, setMeView] = useState<'overview' | 'timesheet'>('overview'); @@ -155,6 +167,49 @@ export const DashboardPage: React.FC = () => { if (!canViewTimesheet && teamView === 'timesheet') setTeamView('overview'); }, [canViewTimesheet, teamView]); + // ── Recent activity β€” everyone's published plan/wrap-up posts for this + // team, live via the same DDP publication the Huddle feed uses. Clicking + // one jumps straight to that post in the feed. + const [recentPosts, setRecentPosts] = useState([]); + useEffect(() => { + if (!selectedTeamId) { + setRecentPosts([]); + return; + } + const ddp = getDdpClient(); + const syncPosts = () => { + const docs = ddp.docs('huddlePosts'); + const teamPosts = docs + .filter((p) => p.teamId === selectedTeamId && p.status !== 'draft') + .map((p) => ({ ...p, id: (p.id ?? p._id) as string }) as unknown as HuddlePost) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) + .slice(0, 6); + setRecentPosts(teamPosts); + }; + syncPosts(); + const offChange = ddp.onCollectionChange('huddlePosts', syncPosts); + const unsubscribe = ddp.subscribe('huddlePosts.byTeam', [selectedTeamId], syncPosts); + return () => { + offChange(); + unsubscribe(); + setRecentPosts([]); + }; + }, [selectedTeamId]); + + const goToPost = (postId: string) => navigate(`/app/huddle?postId=${postId}`); + + const formatPostTimestamp = (date: string) => { + const diffMs = Date.now() - new Date(date).getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + if (diffMins < 1) return 'just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + return new Date(date).toLocaleDateString(); + }; + const fetchData = useCallback(async () => { if (!user || !selectedTeamId) return; setLoading(true); @@ -745,6 +800,59 @@ export const DashboardPage: React.FC = () => {
    )} + + {/* ── Recent activity β€” everyone's plan/wrap-up posts ──────────────── */} + + + + + Recent activity + + + + + {recentPosts.length === 0 ? ( +
    + + No plan posts or clock-ins yet + +
    + ) : ( +
      + {recentPosts.map((post) => ( +
    • + +
    • + ))} +
    + )} +
    +
    )} diff --git a/src/features/huddle/PostCard/index.tsx b/src/features/huddle/PostCard/index.tsx index f4afaec3..225ed118 100644 --- a/src/features/huddle/PostCard/index.tsx +++ b/src/features/huddle/PostCard/index.tsx @@ -66,6 +66,9 @@ interface PostCardProps { canEdit: boolean; canDelete: boolean; onPostUpdated?: () => void; + /** Briefly highlighted when navigated to directly (e.g. from the dashboard's + * Recent Activity feed via `/app/huddle?postId=`). */ + highlighted?: boolean; } export function PostCard({ @@ -74,6 +77,7 @@ export function PostCard({ canEdit, canDelete, onPostUpdated, + highlighted, }: PostCardProps) { const [isEditing, setIsEditing] = useState(false); const [showMenu, setShowMenu] = useState(false); @@ -169,8 +173,11 @@ export function PostCard({ return (
    {/* ── Author header ── */}
    diff --git a/src/pages/Huddle.tsx b/src/pages/Huddle.tsx index e2e86afc..d2276f11 100644 --- a/src/pages/Huddle.tsx +++ b/src/pages/Huddle.tsx @@ -45,6 +45,34 @@ export default function Huddle() { const { user } = useSession(); const { selectedTeamId } = useTeam(); + // Deep-link support: /app/huddle?postId=XXX (e.g. from the dashboard's + // Recent Activity feed) β€” scroll to and briefly highlight that post once + // it's loaded, then strip the query param. + const [targetPostId, setTargetPostId] = useState(() => { + if (typeof window === 'undefined') return null; + return new URLSearchParams(window.location.search).get('postId'); + }); + const [highlightedPostId, setHighlightedPostId] = useState(null); + + useEffect(() => { + if (!targetPostId) return; + setFeedTab('feed'); + setFeedView('cards'); + }, [targetPostId]); + + useEffect(() => { + if (!targetPostId || loading) return; + if (!posts.some((p) => p.id === targetPostId)) return; + document + .getElementById(`huddle-post-${targetPostId}`) + ?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setHighlightedPostId(targetPostId); + window.history.replaceState(null, '', window.location.pathname); + setTargetPostId(null); + const timer = setTimeout(() => setHighlightedPostId(null), 2500); + return () => clearTimeout(timer); + }, [targetPostId, loading, posts]); + // Load team data for permission checks useEffect(() => { async function loadTeam() { @@ -353,6 +381,7 @@ export default function Huddle() { currentUserId={user?.id ?? ''} canEdit={canEditPost(post)} canDelete={canDeletePost(post)} + highlighted={post.id === highlightedPostId} /> ))} From ac918ff5b2e7a7e7d6ff0d24fa990c45ceed7e1c Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Tue, 4 Aug 2026 15:04:38 -0400 Subject: [PATCH 37/66] Recent Activity: teams only, roomier row layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DashboardPage: only show the Recent Activity card for actual teams β€” a personal workspace has no 'everyone' to show activity for. Rows now use a plain - - - {recentPosts.length === 0 ? ( -
    - - No plan posts or clock-ins yet - -
    - ) : ( -
      - {recentPosts.map((post) => ( -
    • - + + + {recentPosts.length === 0 ? ( +
      + + No plan posts or clock-ins yet + +
      + ) : ( +
        + {recentPosts.map((post) => ( +
      • + -
      • - ))} -
      - )} -
      - + + {post.clockEventId ? 'Posted their plan & clocked in' : 'Shared an update'} + + {post.content.text && ( + + {post.content.text} + + )} +
    + + {formatPostTimestamp(post.createdAt)} + + + + ))} + + )} + + + )} )} From c16e08d90083ae10a5943ceafe43d879078fcb07 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Tue, 4 Aug 2026 15:11:52 -0400 Subject: [PATCH 38/66] Huddle: dismiss post highlight ring on click The indigo highlight ring around a deep-linked post (from the dashboard's Recent Activity feed) now clears as soon as the user clicks/taps anywhere, instead of only after the 2.5s timeout. --- src/pages/Huddle.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/pages/Huddle.tsx b/src/pages/Huddle.tsx index d2276f11..80c5c127 100644 --- a/src/pages/Huddle.tsx +++ b/src/pages/Huddle.tsx @@ -73,6 +73,15 @@ export default function Huddle() { return () => clearTimeout(timer); }, [targetPostId, loading, posts]); + // Dismiss the highlight ring as soon as the user clicks/taps anywhere, + // rather than waiting out the full timeout. + useEffect(() => { + if (!highlightedPostId) return; + const clear = () => setHighlightedPostId(null); + document.addEventListener('pointerdown', clear); + return () => document.removeEventListener('pointerdown', clear); + }, [highlightedPostId]); + // Load team data for permission checks useEffect(() => { async function loadTeam() { From e37d5cfdf21be66141bdd194eb31b85a4759e3e8 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Tue, 4 Aug 2026 15:31:32 -0400 Subject: [PATCH 39/66] Fix Prettier formatting --- src/features/dashboard/DashboardPage.tsx | 10 ++++++++-- src/ui/BottomNav.tsx | 11 +++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index 719f61c8..2b2f02d1 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -844,7 +844,9 @@ export const DashboardPage: React.FC = () => { variant="muted" className="mt-0.5 leading-snug text-neutral-500 dark:text-neutral-400" > - {post.clockEventId ? 'Posted their plan & clocked in' : 'Shared an update'} + {post.clockEventId + ? 'Posted their plan & clocked in' + : 'Shared an update'} {post.content.text && ( { )}
    - + {formatPostTimestamp(post.createdAt)} diff --git a/src/ui/BottomNav.tsx b/src/ui/BottomNav.tsx index fc134efc..87452b51 100644 --- a/src/ui/BottomNav.tsx +++ b/src/ui/BottomNav.tsx @@ -176,12 +176,11 @@ export const BottomNav: React.FC = () => { }, ]; - const SECTION_CONFIG: Record, { label: string; rows: MoreRow[] }> = - { - admin: { label: 'Admin', rows: adminRows }, - developers: { label: 'Developers', rows: developerRows }, - help: { label: 'Help', rows: helpRows }, - }; + const SECTION_CONFIG: Record, { label: string; rows: MoreRow[] }> = { + admin: { label: 'Admin', rows: adminRows }, + developers: { label: 'Developers', rows: developerRows }, + help: { label: 'Help', rows: helpRows }, + }; // Plan-first gate: the FAB always navigates to the clock page (where the // inline composer lives) in full color β€” it's a link, not a disabled From e132e9ed94179754c428391898e1a882fe296480 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Tue, 4 Aug 2026 15:35:25 -0400 Subject: [PATCH 40/66] Huddle PostCard: edge-to-edge actions divider + clickable author The border above the like/comment/share row now stretches to the card's full corners (-mx-5/px-5 instead of -mx-1) instead of stopping short. Clicking the author avatar or name in the huddle feed now navigates to their profile page. --- src/features/huddle/PostCard/index.tsx | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/features/huddle/PostCard/index.tsx b/src/features/huddle/PostCard/index.tsx index 225ed118..5ba320ab 100644 --- a/src/features/huddle/PostCard/index.tsx +++ b/src/features/huddle/PostCard/index.tsx @@ -6,6 +6,7 @@ import { HuddleComments } from '../HuddleComments'; import { HuddleComposer } from '../HuddleComposer'; import { toPostAttachment } from '../api'; import type { ComposerContent, MediaItem } from '../types'; +import { useRouter } from '../../../ui/router'; import { Share } from '@capacitor/share'; import { Capacitor } from '@capacitor/core'; @@ -170,6 +171,8 @@ export function PostCard({ const authorName = (post as any).userName || 'Unknown User'; const authorInitials = (post as any).userInitials || getUserInitials(authorName); const avatarColor = getUserColor(post.userId); + const { navigate } = useRouter(); + const goToAuthorProfile = () => navigate(`/app/profile/${post.userId}`); return (
    {/* ── Author header ── */}
    - +
    - {authorName} + {formatTimestamp(post.createdAt)} @@ -358,7 +374,7 @@ export function PostCard({ )} {/* ── Actions ── */} -
    +
    + has the Clock In tab/FAB in BottomNav. Hidden while already on + the clock page (its own Clock In/Out button covers that, and + duplicating the accessible name breaks role-based selectors). */} + {!onClockPage && ( + + )} )} - ))} -
    - -
    - - {/* Custom date inputs */} - {preset === 'custom' && ( -
    - setCustomStart(e.target.value)} - size="sm" - /> - setCustomEnd(e.target.value)} - size="sm" - /> - -
    - )} - - {/* Summary stats */} - {data && ( -
    - - - - Total Hours - - - {formatDuration(roundDurationSecondsForDisplay(filteredSummary.totalSeconds))} - - - - - - - Break Hours - - - {formatDuration( - roundDurationSecondsForDisplay(filteredSummary.totalBreakSeconds), - )} - - - - - - - Sessions - - - {filteredSummary.totalSessions} - - - - - - - Avg Session - - - {formatDuration( - roundDurationSecondsForDisplay(filteredSummary.averageSessionSeconds), - )} - - - - - - - Working Days - - - {filteredSummary.workingDays} - - - -
    - )} - - {/* Loading */} - {loading && ( -
    - -
    - )} - - {/* Error */} - {error && ( - - {error} - - )} -
    - - {/* Flexible bottom: sessions list / empty state */} -
    - {data && filteredSessions.length > 0 && ( - - - Sessions - - {filteredSessions.length} - - -
    - - - - Date - Clock In - Clock Out - Duration - Team - Status - Actions - - - - {filteredSessions.map((s) => ( - - ))} - -
    -
    -
    - )} - - {data && filteredSessions.length === 0 && !loading && ( - - - - - No clock events in this date range. - - - - )} -
    - - { - setSessionDialogOpen(open); - if (!open) { - setActiveSession(null); - setEditBreaks([]); - setSessionSaveError(null); - } - }} - aria-labelledby="edit-session-title" - > - - - Edit Session - - - - {editTeamName && ( -
    - - Team - - - {editTeamName} - -
    - )} - setEditClockIn(e.target.value)} - /> - setEditClockOut(e.target.value)} - placeholder="Leave blank to keep active" - /> - {editDurationSeconds !== null && ( -
    - - Duration - - - {formatDuration(roundDurationSecondsForDisplay(editDurationSeconds))} - -
    - )} -
    -
    - - Breaks - - -
    - {editBreaks.length === 0 ? ( - - No breaks configured. - - ) : ( -
    - {editBreaks.map((brk, idx) => ( -
    - - Break {idx + 1} - - - setEditBreaks((prev) => - prev.map((entry) => - entry.id === brk.id ? { ...entry, start: e.target.value } : entry, - ), - ) - } - /> - - setEditBreaks((prev) => - prev.map((entry) => - entry.id === brk.id ? { ...entry, end: e.target.value } : entry, - ), - ) - } - placeholder="Leave blank for open break" - /> -
    - -
    -
    - ))} -
    - )} -
    - {sessionSaveError && ( - - {sessionSaveError} - - )} - {activeSession && ( - - )} -
    - -
    - - - {activeSession?.endTime !== null && ( - - )} -
    -
    -
    - - {/* Add Entry modal */} - { - setAddEntryOpen(open); - if (!open) setAddEntryError(null); - }} - aria-labelledby="add-entry-title" - > - - - Add Past Entry - - - - setNewClockIn(e.target.value)} - /> - setNewClockOut(e.target.value)} - /> - {newDurationSeconds !== null && ( -
    - - Duration - - - {formatDuration(roundDurationSecondsForDisplay(newDurationSeconds))} - -
    - )} - {addEntryError && ( - - {addEntryError} - - )} -
    - -
    - - -
    -
    -
    - - ); -}; diff --git a/src/ui/AppHeader.tsx b/src/ui/AppHeader.tsx index 45db57fc..f698aab4 100644 --- a/src/ui/AppHeader.tsx +++ b/src/ui/AppHeader.tsx @@ -2,17 +2,28 @@ * AppHeader β€” Sticky top bar. * * Left : org/team switcher - * Right : clock-in timer (if active), clock page shortcut button, - * notifications bell, UserDropdown + * Right : clock controls, notifications bell, UserDropdown + * + * The clock controls swap on state so the primary action is always the + * obvious one: + * β€’ clocked out β€” a filled "Clock In" pill that reads as the call to action + * β€’ clocked in β€” a gradient strand travelling across the bar (desktop, the + * one place its helix detail is legible), the live timer (tap for the clock + * page) and Break/Resume + * + * Both are withheld on the clock page itself, which carries its own larger + * versions of the same controls; duplicating their accessible names would also + * break role-based selectors. * * The page title lives in the body, not here β€” see ui/pageTitle.tsx. */ -import { faBell, faClock } from '@fortawesome/free-solid-svg-icons'; +import { faBell, faClock, faMugHot, faPlay } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Button } from '@mieweb/ui'; import React from 'react'; import { useTeam } from '../lib/TeamContext'; +import { useClockBreak } from '../lib/useClockBreak'; import { ClockInHeaderTimer } from './ClockInHeaderTimer'; import { OrgTeamSwitcher } from './OrgTeamSwitcher'; import { useRouter } from './router'; @@ -21,7 +32,9 @@ import { UserDropdown } from './UserDropdown'; export const AppHeader: React.FC = () => { const { navigate, pathname } = useRouter(); const { activeClockEvent } = useTeam(); + const { isPaused, toggleBreak, clockPauseLoading } = useClockBreak(); const onClockPage = pathname.startsWith('/app/clock'); + const isClockedIn = !!activeClockEvent; return (
    @@ -32,31 +45,50 @@ export const AppHeader: React.FC = () => {
    - {/* ── Right ── */} + {/* ── Right ── */}}
    - {/* Clock-in timer (visible when clocked in) */} + {/* Clock-in timer (visible when clocked in) β€” tapping opens the + clock page, so no separate shortcut button is needed here. */} - {/* Direct shortcut to the clock page β€” desktop only; mobile already - has the Clock In tab/FAB in BottomNav. Hidden while already on - the clock page (its own Clock In/Out button covers that, and - duplicating the accessible name breaks role-based selectors). */} - {!onClockPage && ( - - )} + + {!onClockPage && + (isClockedIn ? ( + /* ── Break / Resume β€” the one action you actually want mid-shift, + so it's a filled pill in both states rather than a quiet + outline that disappears into the header chrome. Colours come + from the @mieweb/ui variant, not hand-painted classes; the + icon carries which state you're in, and the timer beside it + goes neutral and stops animating while paused. The label is + hidden on the narrowest screens; the icon and aria-label + still carry it. ── */ + + ) : ( + /* ── Clock In β€” a filled pill so the primary action stands out + against the neutral header chrome. ── */ + + ))} + +
    + )} {requirePlan && ( Plan required for this team @@ -401,9 +469,9 @@ export const ClockPage: React.FC = () => { {/* ── Composer β€” plan before clock-in / wrap-up before clock-out ── */} {composerMode && ( -
    +
    - + {composerTitle} {composerDescription && ( @@ -489,60 +557,40 @@ export const ClockPage: React.FC = () => {
    )} - {/* ── Plain actions when the gate is satisfied (or off) ── */} + {/* ── Plain actions when the gate is satisfied (or off) ── + The one thing you came to this page to do, so it's a large pill + with an icon rather than a default-sized button in a row of them. */} {!composerMode && ( -
    +
    {!isClockedIn ? ( ) : ( - <> - - - + )}
    )} - {/* Break/Resume stays reachable while the wrap-up composer is up */} - {composerMode === 'wrapup' && ( -
    - -
    - )} - {clockOutBlockedReason && ( {clockOutBlockedReason} From 0f290de94ddeae00fa741766f63c79d2658416c6 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 5 Aug 2026 19:31:56 -0400 Subject: [PATCH 54/66] Notify team members when plan-required setting is enabled After updateSettings saves requirePlanForClock: true, fan-out a notification to every team member (except the admin who made the change) via createNotification, which persists to the inbox and fires a push. --- meteor-backend/server/teams.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/meteor-backend/server/teams.js b/meteor-backend/server/teams.js index c02a35b1..102d0ffc 100644 --- a/meteor-backend/server/teams.js +++ b/meteor-backend/server/teams.js @@ -478,6 +478,25 @@ Meteor.methods({ if (autoAcceptJoins !== undefined) $set['settings.autoAcceptJoins'] = autoAcceptJoins; await Teams.updateAsync(team._id, { $set }); const updated = await Teams.findOneAsync(team._id); + + // Notify all members (except the admin making the change) when plan-gate is enabled + if (requirePlanForClock === true && !team.settings?.requirePlanForClock) { + const memberIds = Array.from(new Set([...team.members, ...team.admins])).filter( + (id) => id !== userId, + ); + const teamLabel = team.name ?? 'Your team'; + await Promise.allSettled( + memberIds.map((memberId) => + createNotification({ + userId: memberId, + title: `${teamLabel} now requires a plan before clocking in`, + body: 'Write a short plan on the Clock In/Out page before starting your next shift.', + data: { type: 'team-setting-change', url: '/app/clock', teamId: String(team._id) }, + }), + ), + ); + } + return { team: toPublicTeam(updated) }; }, From 6237b2ee64e6060e3d77d82057f5578e3188e044 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 5 Aug 2026 19:32:56 -0400 Subject: [PATCH 55/66] Fix stray brace parse error in AppHeader --- src/ui/AppHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/AppHeader.tsx b/src/ui/AppHeader.tsx index f698aab4..c05aa010 100644 --- a/src/ui/AppHeader.tsx +++ b/src/ui/AppHeader.tsx @@ -45,7 +45,7 @@ export const AppHeader: React.FC = () => {
    - {/* ── Right ── */}} + {/* ── Right ── */}
    {/* Clock-in timer (visible when clocked in) β€” tapping opens the clock page, so no separate shortcut button is needed here. */} From 14f6a58ea1de682f6bc8e0066080d9d3c0aaf665 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 5 Aug 2026 19:41:47 -0400 Subject: [PATCH 56/66] Default ticket Team filter to the currently selected team Initialises teamFilter from selectedTeamId so the list opens pre-filtered to the active team. A useEffect also follows the team switcher so changing teams in the header updates the filter automatically. --- src/features/tickets/TicketsPage.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/features/tickets/TicketsPage.tsx b/src/features/tickets/TicketsPage.tsx index 81cb06b3..a8d87c3d 100644 --- a/src/features/tickets/TicketsPage.tsx +++ b/src/features/tickets/TicketsPage.tsx @@ -723,6 +723,11 @@ export const TicketsPage: React.FC = () => { void fetchRunningTimer(); }, [refetch, fetchRunningTimer]); + // When the user switches team in the header, follow the new team in the filter. + useEffect(() => { + if (selectedTeamId) setTeamFilter(selectedTeamId); + }, [selectedTeamId]); + // Pull-to-refresh handler useRefresh(refetch); @@ -847,7 +852,7 @@ export const TicketsPage: React.FC = () => { // Search + filter const [searchQuery, setSearchQuery] = useState(''); - const [teamFilter, setTeamFilter] = useState(null); + const [teamFilter, setTeamFilter] = useState(() => selectedTeamId ?? null); const [assigneeFilter, setAssigneeFilter] = useState(null); const [statusDetailFilter, setStatusDetailFilter] = useState(null); const [priorityFilter, setPriorityFilter] = useState(null); From 7f5ecfdbc8fdd9267e0f60720a9c50986f2afcc7 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 5 Aug 2026 19:45:24 -0400 Subject: [PATCH 57/66] Add ClockStrand, WorkspaceGreeting, PersonalTimesheetPanel, useClockBreak, useWorkspaceGreeting --- src/features/clock/PersonalTimesheetPanel.tsx | 914 ++++++++++++++++++ src/lib/useClockBreak.ts | 55 ++ src/lib/useWorkspaceGreeting.ts | 50 + src/ui/ClockStrand.tsx | 108 +++ src/ui/WorkspaceGreeting.tsx | 106 ++ 5 files changed, 1233 insertions(+) create mode 100644 src/features/clock/PersonalTimesheetPanel.tsx create mode 100644 src/lib/useClockBreak.ts create mode 100644 src/lib/useWorkspaceGreeting.ts create mode 100644 src/ui/ClockStrand.tsx create mode 100644 src/ui/WorkspaceGreeting.tsx diff --git a/src/features/clock/PersonalTimesheetPanel.tsx b/src/features/clock/PersonalTimesheetPanel.tsx new file mode 100644 index 00000000..3a1e494b --- /dev/null +++ b/src/features/clock/PersonalTimesheetPanel.tsx @@ -0,0 +1,914 @@ +/** + * PersonalTimesheetPanel β€” The signed-in user's own clock history. + * + * Features: + * β€’ Date range presets (Today, Yesterday, 7d, This Week, 14d, Custom) + * β€’ Session list with date, times, duration, team name, tickets + * β€’ Summary stats (total hours, break hours, sessions, avg, working days) + * β€’ Add Entry (manual past session) and per-session edit/delete with breaks + * + * Rendered by the Dashboard's Me β†’ Timesheet view. There is no member picker + * here by design: this panel is always scoped to the current user. Admins + * viewing someone else use `features/teams/AdminTimesheetPanel`. + */ +import { faCalendar, faPlus } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + Alert, + AlertDescription, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + cn, + Input, + Modal, + ModalBody, + ModalFooter, + ModalHeader, + Select, + Spinner, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, + Text, +} from '@mieweb/ui'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { useTeam } from '../../lib/TeamContext'; +import { formatDuration } from '../../lib/timeUtils'; +import { ApiError, clockApi, type ClockEvent } from '../../lib/api'; +import { useSession } from '../../lib/useSession'; +import { useRefresh } from '../../lib/RefreshContext'; +import { getDdpClient } from '../../lib/ddp'; +import { AttachmentsPanel } from './AttachmentsPanel'; +import { TimesheetRow } from './TimesheetRow'; +import { + fromLocalDateTimeInputValue, + getDateRange, + getLocalDateKey, + PRESETS, + roundDurationSecondsForDisplay, + toLocalDateTimeInputValue, + type Preset, +} from './timesheetUtils'; + +interface TimesheetData { + sessions: ClockEvent[]; + summary: { + totalSeconds: number; + totalBreakSeconds: number; + totalSessions: number; + completedSessions: number; + averageSessionSeconds: number; + workingDays: number; + }; +} + +interface EditableBreak { + id: string; + start: string; + end: string; +} + +interface Props { + /** + * The panel owns the remaining page height and scrolls the session list + * internally, for a host that gives it the whole screen. Embedded uses (the + * Dashboard) leave this off and stack normally, with the list capped so it + * doesn't run away down the page. + */ + fill?: boolean; +} + +function getSessionWorkSeconds(session: ClockEvent, now: number): number { + if (session.endTime === null) { + if (typeof session.workSeconds === 'number') return Math.max(0, session.workSeconds); + const accumulated = Math.max(0, session.accumulatedTime ?? 0); + if (session.isPaused) return accumulated; + return accumulated + Math.max(0, Math.floor((now - session.startTime) / 1000)); + } + + const accumulated = Math.max(0, session.accumulatedTime ?? 0); + if (accumulated > 0) return accumulated; + return Math.max(0, Math.floor((session.endTime - session.startTime) / 1000)); +} + +function getSessionBreakSeconds(session: ClockEvent, now: number): number { + const breaks = Array.isArray(session.breaks) ? session.breaks : []; + return breaks.reduce((sum, brk) => { + if (typeof brk.startTime !== 'number') return sum; + // A break with no endTime is only truly "still open" while its session is + // still open. If the session already ended, clamp to the session's end + // instead of `now` β€” otherwise a dangling break on a completed session + // accrues phantom hours forever. Matches TimesheetRow's buildTimelineRows. + const end = typeof brk.endTime === 'number' ? brk.endTime : (session.endTime ?? now); + if (end <= brk.startTime) return sum; + return sum + Math.max(0, Math.floor((end - brk.startTime) / 1000)); + }, 0); +} + +export const PersonalTimesheetPanel: React.FC = ({ fill }) => { + const { user } = useSession(); + const { teamsReady, teams, selectedTeamId, currentTime } = useTeam(); + + const [preset, setPreset] = useState('week'); + const [customStart, setCustomStart] = useState(''); + const [customEnd, setCustomEnd] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const [activeSession, setActiveSession] = useState(null); + const [sessionDialogOpen, setSessionDialogOpen] = useState(false); + const [editClockIn, setEditClockIn] = useState(''); + const [editClockOut, setEditClockOut] = useState(''); + const [editBreaks, setEditBreaks] = useState([]); + const [sessionSaveLoading, setSessionSaveLoading] = useState(false); + const [sessionDeleteLoading, setSessionDeleteLoading] = useState(false); + const [sessionSaveError, setSessionSaveError] = useState(null); + + // Add entry modal state + const [addEntryOpen, setAddEntryOpen] = useState(false); + const [newClockIn, setNewClockIn] = useState(''); + const [newClockOut, setNewClockOut] = useState(''); + const [newTeamId, setNewTeamId] = useState(''); + const [addEntryLoading, setAddEntryLoading] = useState(false); + const [addEntryError, setAddEntryError] = useState(null); + + // Guards against out-of-order responses: fetchData can be triggered + // repeatedly in quick succession (preset change, Apply click, DDP live + // update, pull-to-refresh) with no cancellation, so a slower earlier + // request can resolve after a faster later one and silently overwrite + // its correct result with stale data. Each call claims the next id and + // only applies its response if it's still the most recent call. + const fetchRequestIdRef = useRef(0); + + const fetchData = useCallback(async () => { + if (!user?.id) return; + let startMs: number; + let endMs: number; + + if (preset === 'custom') { + if (!customStart || !customEnd) return; + startMs = new Date(`${customStart}T00:00:00`).getTime(); + endMs = new Date(`${customEnd}T23:59:59.999`).getTime(); + } else { + const [s, e] = getDateRange(preset); + startMs = s.getTime(); + endMs = e.getTime(); + } + + const requestId = ++fetchRequestIdRef.current; + setLoading(true); + setError(null); + try { + const result = await clockApi.getTimesheet(user?.id ?? '', startMs, endMs); + if (fetchRequestIdRef.current !== requestId) return; + setData(result); + } catch (e) { + if (fetchRequestIdRef.current !== requestId) return; + setError(e instanceof Error ? e.message : 'Failed to load timesheet'); + } finally { + if (fetchRequestIdRef.current === requestId) setLoading(false); + } + }, [user?.id, preset, customStart, customEnd]); + + useEffect(() => { + void fetchData(); + }, [preset]); + + // ── Real-time timesheet updates (Meteor DDP, oplog-backed) ── + useEffect(() => { + if (!user?.id) return; + const ddp = getDdpClient(); + const offChange = ddp.onCollectionChange('clockevents', () => { + void fetchData(); + }); + const unsubscribe = ddp.subscribe('clock.liveForUser', [user.id]); + return () => { + offChange(); + unsubscribe(); + }; + }, [user?.id, fetchData]); + + // Pull-to-refresh + useRefresh(fetchData); + + const openSessionDialog = useCallback((session: ClockEvent) => { + setActiveSession(session); + setEditClockIn(toLocalDateTimeInputValue(session.originalStartTime ?? session.startTime)); + setEditClockOut(session.endTime ? toLocalDateTimeInputValue(session.endTime) : ''); + const nextBreaks = (Array.isArray(session.breaks) ? session.breaks : []) + .filter((brk) => typeof brk.startTime === 'number') + .sort((a, b) => a.startTime - b.startTime) + .map((brk, idx) => ({ + id: `${session.id}-break-${idx}`, + start: toLocalDateTimeInputValue(brk.startTime), + end: typeof brk.endTime === 'number' ? toLocalDateTimeInputValue(brk.endTime) : '', + })); + setEditBreaks(nextBreaks); + setSessionSaveError(null); + setSessionDialogOpen(true); + }, []); + + const handleSaveSession = useCallback(async () => { + if (!activeSession) return; + + const parsedStart = fromLocalDateTimeInputValue(editClockIn); + if (parsedStart === null) { + setSessionSaveError('Enter a valid clock-in date and time.'); + return; + } + + const now = Date.now(); + if (parsedStart > now) { + setSessionSaveError('Clock-in time cannot be in the future.'); + return; + } + + let parsedEnd: number | null = null; + if (editClockOut.trim()) { + parsedEnd = fromLocalDateTimeInputValue(editClockOut); + if (parsedEnd === null) { + setSessionSaveError('Enter a valid clock-out date and time, or leave it blank.'); + return; + } + if (parsedEnd > now) { + setSessionSaveError('Clock-out time cannot be in the future.'); + return; + } + } + + const parsedBreaks: Array<{ startTime: number; endTime: number | null }> = []; + for (const brk of editBreaks) { + const startInput = brk.start.trim(); + const endInput = brk.end.trim(); + + if (!startInput && !endInput) continue; + if (!startInput) { + setSessionSaveError('Each break must include a start time.'); + return; + } + + const breakStart = fromLocalDateTimeInputValue(startInput); + if (breakStart === null) { + setSessionSaveError('Enter a valid break start time.'); + return; + } + + if (breakStart < parsedStart) { + setSessionSaveError('Break start cannot be earlier than clock-in.'); + return; + } + + if (parsedEnd !== null && breakStart >= parsedEnd) { + setSessionSaveError('Break start must be before clock-out.'); + return; + } + + let breakEnd: number | null = null; + if (endInput) { + breakEnd = fromLocalDateTimeInputValue(endInput); + if (breakEnd === null) { + setSessionSaveError('Enter a valid break end time.'); + return; + } + if (breakEnd <= breakStart) { + setSessionSaveError('Break end must be later than break start.'); + return; + } + if (parsedEnd !== null && breakEnd > parsedEnd) { + setSessionSaveError('Break end cannot be later than clock-out.'); + return; + } + } else if (parsedEnd !== null) { + setSessionSaveError('Completed sessions require a break end time.'); + return; + } + + parsedBreaks.push({ startTime: breakStart, endTime: breakEnd }); + } + + setSessionSaveLoading(true); + setSessionSaveError(null); + try { + await clockApi.updateTimes(activeSession.id, { + startTime: parsedStart, + endTime: parsedEnd, + breaks: parsedBreaks, + }); + setSessionDialogOpen(false); + setActiveSession(null); + setEditBreaks([]); + await fetchData(); + } catch (e) { + if (e instanceof ApiError) { + setSessionSaveError(e.message); + } else { + setSessionSaveError('Unable to update session times.'); + } + } finally { + setSessionSaveLoading(false); + } + }, [activeSession, editBreaks, editClockIn, editClockOut, fetchData]); + + const handleDeleteSession = useCallback(async () => { + if (!activeSession) return; + + setSessionDeleteLoading(true); + setSessionSaveError(null); + try { + await clockApi.deleteEvent(activeSession.id); + setSessionDialogOpen(false); + setActiveSession(null); + setEditBreaks([]); + await fetchData(); + } catch (e) { + if (e instanceof ApiError) { + setSessionSaveError(e.message); + } else { + setSessionSaveError('Unable to delete session.'); + } + } finally { + setSessionDeleteLoading(false); + } + }, [activeSession, fetchData]); + + const openAddEntry = useCallback(() => { + setNewClockIn(''); + setNewClockOut(''); + setNewTeamId(selectedTeamId ?? teams[0]?.id ?? ''); + setAddEntryError(null); + setAddEntryOpen(true); + }, [selectedTeamId, teams]); + + const handleAddEntry = useCallback(async () => { + const parsedStart = fromLocalDateTimeInputValue(newClockIn); + if (parsedStart === null) { + setAddEntryError('Enter a valid clock-in date and time.'); + return; + } + const parsedEnd = fromLocalDateTimeInputValue(newClockOut); + if (parsedEnd === null) { + setAddEntryError('Enter a valid clock-out date and time.'); + return; + } + const now = Date.now(); + if (parsedStart > now || parsedEnd > now) { + setAddEntryError('Times cannot be in the future.'); + return; + } + if (parsedEnd <= parsedStart) { + setAddEntryError('Clock-out must be after clock-in.'); + return; + } + if (!newTeamId) { + setAddEntryError('Please select a team.'); + return; + } + setAddEntryLoading(true); + setAddEntryError(null); + try { + await clockApi.createManualEntry({ + teamId: newTeamId, + startTime: parsedStart, + endTime: parsedEnd, + }); + setAddEntryOpen(false); + setNewClockIn(''); + setNewClockOut(''); + setNewTeamId(''); + await fetchData(); + } catch (e) { + if (e instanceof ApiError) { + setAddEntryError(e.message); + } else { + setAddEntryError('Unable to create entry.'); + } + } finally { + setAddEntryLoading(false); + } + }, [newClockIn, newClockOut, newTeamId, fetchData]); + + // Duration previews (display-only, computed from inputs) + const editDurationSeconds = useMemo(() => { + if (!editClockIn || !editClockOut) return null; + const s = fromLocalDateTimeInputValue(editClockIn); + const e = fromLocalDateTimeInputValue(editClockOut); + if (!s || !e || e <= s) return null; + const breakSeconds = editBreaks.reduce((sum, brk) => { + const bs = fromLocalDateTimeInputValue(brk.start); + const be = fromLocalDateTimeInputValue(brk.end); + if (!bs || !be || be <= bs) return sum; + if (be <= s || bs >= e) return sum; + const clipStart = Math.max(bs, s); + const clipEnd = Math.min(be, e); + if (clipEnd <= clipStart) return sum; + return sum + Math.floor((clipEnd - clipStart) / 1000); + }, 0); + return Math.max(0, Math.floor((e - s) / 1000) - breakSeconds); + }, [editBreaks, editClockIn, editClockOut]); + + const newDurationSeconds = useMemo(() => { + if (!newClockIn || !newClockOut) return null; + const s = fromLocalDateTimeInputValue(newClockIn); + const e = fromLocalDateTimeInputValue(newClockOut); + if (!s || !e || e <= s) return null; + return Math.floor((e - s) / 1000); + }, [newClockIn, newClockOut]); + + // Team name lookup for edit modal + const editTeamName = useMemo(() => { + if (!activeSession) return ''; + return teams.find((t) => t.id === activeSession.teamId)?.name ?? ''; + }, [activeSession, teams]); + + // Team options for new entry (exclude personal workspace) + const teamOptions = useMemo( + () => teams.filter((t) => !t.isPersonal).map((t) => ({ value: t.id, label: t.name })), + [teams], + ); + + const presets = PRESETS; + + // Filter sessions by selected team + const filteredSessions = useMemo(() => { + if (!data) return []; + return selectedTeamId + ? data.sessions.filter((s) => s.teamId === selectedTeamId) + : data.sessions; + }, [data, selectedTeamId]); + + // Recompute summary from filtered sessions + const filteredSummary = useMemo(() => { + const completed = filteredSessions.filter((s) => s.endTime !== null); + const totalSeconds = filteredSessions.reduce( + (sum, s) => sum + getSessionWorkSeconds(s, currentTime), + 0, + ); + const totalBreakSeconds = filteredSessions.reduce( + (sum, s) => sum + getSessionBreakSeconds(s, currentTime), + 0, + ); + const workingDays = new Set( + filteredSessions.map((s) => getLocalDateKey(s.originalStartTime ?? s.startTime)), + ).size; + return { + totalSeconds, + totalBreakSeconds, + totalSessions: filteredSessions.length, + completedSessions: completed.length, + averageSessionSeconds: completed.length > 0 ? Math.floor(totalSeconds / completed.length) : 0, + workingDays, + }; + }, [filteredSessions, currentTime]); + + if (!teamsReady) { + return ( +
    + +
    + ); + } + + return ( +
    + {/* Fixed top: filters + summary stats */} +
    + {/* Date range filter + Add Entry */} +
    +
    + {presets.map((p) => ( + + ))} +
    + +
    + + {/* Custom date inputs */} + {preset === 'custom' && ( +
    + setCustomStart(e.target.value)} + size="sm" + /> + setCustomEnd(e.target.value)} + size="sm" + /> + +
    + )} + + {/* Summary stats */} + {data && ( +
    + + + + Total Hours + + + {formatDuration(roundDurationSecondsForDisplay(filteredSummary.totalSeconds))} + + + + + + + Break Hours + + + {formatDuration( + roundDurationSecondsForDisplay(filteredSummary.totalBreakSeconds), + )} + + + + + + + Sessions + + + {filteredSummary.totalSessions} + + + + + + + Avg Session + + + {formatDuration( + roundDurationSecondsForDisplay(filteredSummary.averageSessionSeconds), + )} + + + + + + + Working Days + + + {filteredSummary.workingDays} + + + +
    + )} + + {/* Loading */} + {loading && ( +
    + +
    + )} + + {/* Error */} + {error && ( + + {error} + + )} +
    + + {/* Flexible bottom: sessions list / empty state */} +
    + {data && filteredSessions.length > 0 && ( + + + Sessions + + {filteredSessions.length} + + +
    + + + + Date + Clock In + Clock Out + Duration + Team + Status + Actions + + + + {filteredSessions.map((s) => ( + + ))} + +
    +
    +
    + )} + + {data && filteredSessions.length === 0 && !loading && ( + + + + + No clock events in this date range. + + + + )} +
    + + { + setSessionDialogOpen(open); + if (!open) { + setActiveSession(null); + setEditBreaks([]); + setSessionSaveError(null); + } + }} + aria-labelledby="edit-session-title" + > + + + Edit Session + + + + {editTeamName && ( +
    + + Team + + + {editTeamName} + +
    + )} + setEditClockIn(e.target.value)} + /> + setEditClockOut(e.target.value)} + placeholder="Leave blank to keep active" + /> + {editDurationSeconds !== null && ( +
    + + Duration + + + {formatDuration(roundDurationSecondsForDisplay(editDurationSeconds))} + +
    + )} +
    +
    + + Breaks + + +
    + {editBreaks.length === 0 ? ( + + No breaks configured. + + ) : ( +
    + {editBreaks.map((brk, idx) => ( +
    + + Break {idx + 1} + + + setEditBreaks((prev) => + prev.map((entry) => + entry.id === brk.id ? { ...entry, start: e.target.value } : entry, + ), + ) + } + /> + + setEditBreaks((prev) => + prev.map((entry) => + entry.id === brk.id ? { ...entry, end: e.target.value } : entry, + ), + ) + } + placeholder="Leave blank for open break" + /> +
    + +
    +
    + ))} +
    + )} +
    + {sessionSaveError && ( + + {sessionSaveError} + + )} + {activeSession && ( + + )} +
    + +
    + + + {activeSession?.endTime !== null && ( + + )} +
    +
    +
    + + {/* Add Entry modal */} + { + setAddEntryOpen(open); + if (!open) setAddEntryError(null); + }} + aria-labelledby="add-entry-title" + > + + + Add Past Entry + + + + setNewClockIn(e.target.value)} + /> + setNewClockOut(e.target.value)} + /> + {newDurationSeconds !== null && ( +
    + + Duration + + + {formatDuration(roundDurationSecondsForDisplay(newDurationSeconds))} + +
    + )} + {addEntryError && ( + + {addEntryError} + + )} +
    + +
    + + +
    +
    +
    +
    + ); +}; diff --git a/src/lib/useClockBreak.ts b/src/lib/useClockBreak.ts new file mode 100644 index 00000000..854bc578 --- /dev/null +++ b/src/lib/useClockBreak.ts @@ -0,0 +1,55 @@ +/** + * useClockBreak β€” Start/end a break on the active clock session. + * + * Split out of useClockToggle so surfaces that only need Break/Resume β€” the + * app header, for one β€” don't pay for the plan-first gate, which subscribes to + * the team's huddle posts. useClockToggle re-exports these, so there is still + * one implementation of the pause/resume calls. + */ +import { useCallback, useState } from 'react'; + +import { clockApi } from './api'; +import { useTeam } from './TeamContext'; + +export function useClockBreak() { + const { activeClockEvent, selectedTeamId, refetchClock } = useTeam(); + const [clockPauseLoading, setClockPauseLoading] = useState(false); + + const isPaused = !!activeClockEvent?.isPaused; + + // Always prefer the active event's teamId: the user may have switched teams + // after clocking in, and the break belongs to the session, not the selection. + const teamId = activeClockEvent?.teamId ?? selectedTeamId; + + const runBreakAction = useCallback( + async (action: 'pause' | 'resume') => { + if (!teamId) return; + setClockPauseLoading(true); + try { + await (action === 'pause' ? clockApi.pause(teamId) : clockApi.resume(teamId)); + await refetchClock(); + // Notify all timer-displaying pages to refetch immediately + window.dispatchEvent(new CustomEvent('work:refetch')); + window.dispatchEvent(new CustomEvent('tickets:refetch')); + } catch (err) { + window.alert( + err instanceof Error ? err.message : `Failed to ${action} clock. Please try again.`, + ); + } finally { + setClockPauseLoading(false); + } + }, + [teamId, refetchClock], + ); + + const pauseClock = useCallback(() => runBreakAction('pause'), [runBreakAction]); + const resumeClock = useCallback(() => runBreakAction('resume'), [runBreakAction]); + + /** Whichever of pause/resume applies to the current state. */ + const toggleBreak = useCallback( + () => runBreakAction(isPaused ? 'resume' : 'pause'), + [runBreakAction, isPaused], + ); + + return { isPaused, pauseClock, resumeClock, toggleBreak, clockPauseLoading }; +} diff --git a/src/lib/useWorkspaceGreeting.ts b/src/lib/useWorkspaceGreeting.ts new file mode 100644 index 00000000..f8058bc3 --- /dev/null +++ b/src/lib/useWorkspaceGreeting.ts @@ -0,0 +1,50 @@ +/** + * useWorkspaceGreeting β€” a time-of-day greeting plus a plain-language name for + * the workspace currently in scope. + * + * The org/team switcher in the header already names the current team, but it + * reads as a control rather than a statement β€” it's easy to act on the wrong + * workspace without noticing. Pages use this to say it in words, under their + * title, where people actually read. + * + * The hook owns the facts; each page writes its own sentence, since "you're + * viewing X" and "you're tracking time in X" are not the same message. + */ +import { useTeam } from './TeamContext'; +import { useSession } from './useSession'; + +export function useWorkspaceGreeting() { + const { user } = useSession(); + const { teams, selectedTeamId, teamsReady } = useTeam(); + + const selectedTeam = teams.find((t) => t.id === selectedTeamId) ?? null; + const isPersonalWorkspace = Boolean(selectedTeam?.isPersonal); + + // First name only: the full name is already in the account menu, and a long + // one would push this line past the title it sits under. + const firstName = user?.name?.trim().split(/\s+/)[0] ?? ''; + + const hour = new Date().getHours(); + const partOfDay: PartOfDay = hour < 12 ? 'morning' : hour < 17 ? 'afternoon' : 'evening'; + const greeting = firstName ? `Good ${partOfDay}, ${firstName}` : `Good ${partOfDay}`; + + const workspaceLabel = isPersonalWorkspace + ? 'your personal workspace' + : (selectedTeam?.name ?? ''); + + return { + greeting, + partOfDay, + userName: user?.name ?? '', + userImage: user?.image ?? null, + workspaceLabel, + isPersonalWorkspace, + /** + * False until there's a real workspace to name. Pages skip the greeting + * entirely rather than render a half-finished sentence while teams load. + */ + ready: teamsReady && workspaceLabel !== '', + }; +} + +export type PartOfDay = 'morning' | 'afternoon' | 'evening'; diff --git a/src/ui/ClockStrand.tsx b/src/ui/ClockStrand.tsx new file mode 100644 index 00000000..ba8e1536 --- /dev/null +++ b/src/ui/ClockStrand.tsx @@ -0,0 +1,108 @@ +/** + * ClockStrand β€” the shift-state strand. + * + * A line that travels left while the clock counts, and settles flat the moment + * it stops. Motion is the signal: a static icon looks identical whether the + * clock is running or frozen mid-break. + * + * Two shapes: + * β€’ "snake" (default) β€” one line snaking horizontally. The right call almost + * everywhere: it reads at a glance and survives being squeezed into a pill. + * β€’ "helix" β€” two strands wound together with rungs between. Only earns its + * keep at the width of the desktop toolbar, where the detail is legible. + * + * The stroke is a fixed multi-stop gradient rather than a state colour, so it + * reads as one continuous ribbon. Running vs paused is carried by movement, and + * in text beside it. + * + * Purely decorative β€” `aria-hidden` throughout. + */ +import { cn } from '@mieweb/ui'; +import React, { useId } from 'react'; + +// Geometry, in user units. The path spans twice SHIFT so the half overhanging +// the viewBox is what travels in; SHIFT is a whole number of periods, so the +// loop lands on an identical phase and never shows a seam. +const PERIOD = 24; +const AMPLITUDE = 7; +const MID = 12; +const SHIFT = 96; +const TOTAL = SHIFT * 2; + +const waveAt = (x: number) => AMPLITUDE * Math.sin((2 * Math.PI * x) / PERIOD); + +/** One strand. `phase` of -1 gives the mirrored partner used by the helix. */ +function buildStrand(phase: 1 | -1): string { + const points: string[] = []; + for (let x = 0; x <= TOTAL; x += 2) { + points.push(`${x} ${(MID + phase * waveAt(x)).toFixed(2)}`); + } + return `M${points.join(' L')}`; +} + +/** The rungs between helix strands, skipped where the two cross. */ +function buildRungs(): string { + const segments: string[] = []; + for (let x = 0; x <= TOTAL; x += 4) { + const dy = waveAt(x); + if (Math.abs(dy) < 1) continue; + segments.push(`M${x} ${(MID - dy).toFixed(2)}L${x} ${(MID + dy).toFixed(2)}`); + } + return segments.join(''); +} + +const STRAND_A = buildStrand(1); +const STRAND_B = buildStrand(-1); +const RUNGS = buildRungs(); +const FLAT = `M0 ${MID}H${SHIFT}`; + +interface Props { + /** Travelling while the clock counts; a still flat line when it isn't. */ + active: boolean; + /** Shape. Defaults to the single snaking line. */ + variant?: 'snake' | 'helix'; + /** Sizing utilities β€” the strand stretches to fill whatever box it's given. */ + className?: string; +} + +export const ClockStrand: React.FC = ({ active, variant = 'snake', className }) => { + // Gradient defs are document-global, so each instance needs its own id β€” + // several of these can be mounted at once. + const gradientId = `clock-strand-${useId().replace(/[^a-zA-Z0-9]/g, '')}`; + const stroke = `url(#${gradientId})`; + + return ( + + ); +}; diff --git a/src/ui/WorkspaceGreeting.tsx b/src/ui/WorkspaceGreeting.tsx new file mode 100644 index 00000000..ed45b9f8 --- /dev/null +++ b/src/ui/WorkspaceGreeting.tsx @@ -0,0 +1,106 @@ +/** + * WorkspaceGreeting β€” the warm "hello, and here's where you are" banner that + * opens the Dashboard and the Clock page. + * + * Two jobs at once. It greets you, and β€” more usefully β€” it says in plain + * words which workspace you're about to act on. The header's org/team switcher + * technically already shows that, but it reads as a control rather than a + * statement, which is how people end up logging hours against the wrong team. + * + * The shell is shared; the `note` is not. What a workspace *means* differs by + * page ("these are the numbers for X" vs "your hours land in X"), so each page + * passes its own line rather than reusing one vague sentence. + */ +import { faLock, faMoon, faSun, faUsers } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { cn, Text } from '@mieweb/ui'; +import React from 'react'; + +import { useWorkspaceGreeting } from '../lib/useWorkspaceGreeting'; +import { UserAvatar } from './UserAvatar'; + +interface Props { + /** Page-specific line: what this particular page does with the workspace. */ + note?: string; + /** + * Optional block pinned to the trailing edge β€” the Dashboard puts the live + * session summary here rather than spending a whole card on it. + */ + trailing?: React.ReactNode; + className?: string; +} + +export const WorkspaceGreeting: React.FC = ({ note, trailing, className }) => { + const { greeting, partOfDay, userName, userImage, workspaceLabel, isPersonalWorkspace, ready } = + useWorkspaceGreeting(); + + if (!ready) return null; + + const isEvening = partOfDay === 'evening'; + + return ( +
    + {/* Avatar with a sun/moon badge tucked into the corner. */} +
    + + +
    + +
    + + {greeting} + + + {/* Workspace pill β€” the part worth actually noticing. */} +
    + + + + {isPersonalWorkspace ? 'Personal workspace' : workspaceLabel} + + + + {isPersonalWorkspace ? 'Only you can see this' : 'Shared with your team'} + +
    + + {note && ( + + {note} + + )} +
    + + {trailing &&
    {trailing}
    } +
    + ); +}; From 8bb35abec41521fefd0a0e93a14b42b926b5c871 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 5 Aug 2026 19:45:30 -0400 Subject: [PATCH 58/66] Redesign: update Dashboard, Sidebar, BottomNav, CommandPalette, api, useClockToggle --- src/features/dashboard/DashboardPage.tsx | 217 ++++++++++++----------- src/lib/TeamContext.test.tsx | 1 + src/lib/api.ts | 17 +- src/lib/useClockToggle.ts | 40 +---- src/ui/BottomNav.tsx | 11 +- src/ui/CommandPalette.tsx | 2 - src/ui/Sidebar.tsx | 3 - 7 files changed, 138 insertions(+), 153 deletions(-) diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index 94e31b4b..3c4a66f2 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -7,11 +7,16 @@ * 3. Active tickets: Only tickets with running timers, with the person who started each * 4. Time logged today: Per-member bar with hours * - * The "Team" tab also offers a "Timesheet" view (admins only) β€” the - * admin timesheet moved here from the Teams page to keep Teams focused on - * membership/settings and keep time-tracking data alongside the rest of the - * team's activity. - * β€’ Deep-link support: ?tab=timesheet&teamId=XXX&memberId=YYY + * A Me/Team toggle scopes the stats. It is hidden on a personal workspace, + * where both tabs would show the same numbers. + * + * Both tabs offer a "Timesheet" view. "Me" holds the personal timesheet β€” the + * app's only one since the standalone /app/timesheet route was retired. "Team" + * holds the admin timesheet (admins only), which moved here from the Teams page + * to keep Teams focused on membership/settings and keep time-tracking data + * alongside the rest of the team's activity. + * β€’ Deep-link support: ?tab=timesheet&teamId=XXX&memberId=YYY (Team) + * ?view=timesheet (Me) */ import { faClock, @@ -26,19 +31,7 @@ import { faComments, } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { - Alert, - AlertDescription, - AlertTitle, - Badge, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - Spinner, - Text, -} from '@mieweb/ui'; +import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Spinner, Text } from '@mieweb/ui'; import React, { useCallback, useEffect, useState } from 'react'; import { @@ -46,7 +39,6 @@ import { type Ticket, teamApi, type TeamMember, - type TimecoreUser, teamDashboardApi, type TeamMemberClockStatus, type TeamRunningTimer, @@ -56,23 +48,19 @@ import { useSession } from '../../lib/useSession'; import { useTeam } from '../../lib/TeamContext'; import { useRefresh } from '../../lib/RefreshContext'; import { getDdpClient } from '../../lib/ddp'; -import { formatDuration, formatTimer } from '../../lib/timeUtils'; +import { formatDuration, formatTimer, getActiveClockSeconds } from '../../lib/timeUtils'; import { useRouter } from '../../ui/router'; import { AppPage } from '../../ui/AppPage'; import { UserAvatar } from '../../ui/UserAvatar'; +import { ClockStrand } from '../../ui/ClockStrand'; +import { WorkspaceGreeting } from '../../ui/WorkspaceGreeting'; +import { PersonalTimesheetPanel } from '../clock/PersonalTimesheetPanel'; +import { roundDurationSecondsForDisplay } from '../clock/timesheetUtils'; import { AdminTimesheetPanel } from '../teams/AdminTimesheetPanel'; const profilePath = (member: TeamMemberClockStatus) => `/app/profile/${member.username ?? member.userId}`; -const userToTeamMember = (user: TimecoreUser): TeamMember => ({ - id: user.id, - name: user.name, - email: user.email, - username: user.username, - image: user.image ?? null, -}); - // ─── DashboardPage ──────────────────────────────────────────────────────────── export const DashboardPage: React.FC = () => { @@ -90,18 +78,17 @@ export const DashboardPage: React.FC = () => { const selectedTeam = teams.find((t) => t.id === selectedTeamId) ?? null; const teamAdminIds = new Set(selectedTeam?.admins ?? []); - const canViewTimesheet = isAdmin && !selectedTeam?.isPersonal; + const isPersonalWorkspace = Boolean(selectedTeam?.isPersonal); + const canViewTimesheet = isAdmin && !isPersonalWorkspace; const [tickets, setTickets] = useState([]); const [memberStatuses, setMemberStatuses] = useState([]); const [runningTimers, setRunningTimers] = useState([]); const [loading, setLoading] = useState(false); - // "Me" is the default β€” a personal-team user sees their own numbers - // immediately, and can switch to "Team" to see everyone (even if that's - // still just themselves on a personal team; the tab is never hidden). - // Persisted so navigating away and back to the dashboard doesn't silently - // reset the user back to "Me" after they've chosen "Team". - const [tab, _setTab] = useState<'me' | 'team'>(() => { + // "Me" is the default. Persisted so navigating away and back to the + // dashboard doesn't silently reset the user to "Me" after they've chosen + // "Team". + const [storedTab, _setTab] = useState<'me' | 'team'>(() => { if (typeof window === 'undefined') return 'me'; return localStorage.getItem('app:dashboardTab') === 'team' ? 'team' : 'me'; }); @@ -110,6 +97,14 @@ export const DashboardPage: React.FC = () => { if (typeof window !== 'undefined') localStorage.setItem('app:dashboardTab', next); }, []); + // A personal workspace has no one in it but you, so "Me" and "Team" would + // show the same numbers β€” the toggle is hidden there rather than offering a + // choice that changes nothing. The stored preference is left untouched so + // switching back to a real team restores whichever tab was last chosen; it + // just doesn't apply here, or a "team" carried over from a real team would + // strand the user on a tab with no visible way back. + const tab = isPersonalWorkspace ? 'me' : storedTab; + // "Overview" vs "Timesheet" sub-view for the "Me" tab. const [meView, setMeView] = useState<'overview' | 'timesheet'>('overview'); @@ -120,11 +115,14 @@ export const DashboardPage: React.FC = () => { const [initialMemberId, setInitialMemberId] = useState(''); const [teamMembers, setTeamMembers] = useState([]); - // ── Deep-link support: ?tab=timesheet&teamId=&memberId= ── + // ── Deep-link support ── + // ?tab=timesheet&teamId=&memberId= β†’ Team β†’ Timesheet (admin, from notifications) + // ?view=timesheet β†’ Me β†’ Timesheet (the retired /app/timesheet URL) useEffect(() => { if (!teamsReady) return; const params = new URLSearchParams(window.location.search); const deepTab = params.get('tab'); + const deepView = params.get('view'); const memberId = params.get('memberId'); const teamId = params.get('teamId'); @@ -132,10 +130,14 @@ export const DashboardPage: React.FC = () => { setTab('team'); setTeamView('timesheet'); } + if (deepView === 'timesheet') { + setTab('me'); + setMeView('timesheet'); + } if (memberId) setInitialMemberId(memberId); if (teamId && teams.some((t) => t.id === teamId)) setSelectedTeamId(teamId); - if (deepTab || memberId || teamId) { + if (deepTab || deepView || memberId || teamId) { window.history.replaceState(null, '', window.location.pathname); } }, [teamsReady, teams, setSelectedTeamId]); @@ -173,7 +175,7 @@ export const DashboardPage: React.FC = () => { // workspace has no "everyone" to show activity for. const [recentPosts, setRecentPosts] = useState([]); useEffect(() => { - if (!selectedTeamId || selectedTeam?.isPersonal) { + if (!selectedTeamId || isPersonalWorkspace) { setRecentPosts([]); return; } @@ -195,7 +197,7 @@ export const DashboardPage: React.FC = () => { unsubscribe(); setRecentPosts([]); }; - }, [selectedTeamId, selectedTeam?.isPersonal]); + }, [selectedTeamId, isPersonalWorkspace]); const goToPost = (postId: string) => navigate(`/app/huddle?postId=${postId}`); @@ -303,34 +305,71 @@ export const DashboardPage: React.FC = () => { return ( - - -
    + isPersonalWorkspace ? undefined : ( +
    + + +
    + ) } > + + +
    + + {activeClockEvent.isPaused ? 'On break' : 'Session active'} + + + {formatTimer(getActiveClockSeconds(activeClockEvent, currentTime))} + +
    + +
    + ) + } + /> + {/* ── Me / Timesheet toggle ─────────────────────────────────────── */} {tab === 'me' && (
    @@ -361,15 +400,9 @@ export const DashboardPage: React.FC = () => {
    )} - {/* ── Me Timesheet view ────────────────────────────────────────────── */} - {tab === 'me' && meView === 'timesheet' && user && selectedTeamId && ( - - )} + {/* ── Me Timesheet view β€” the app's only personal timesheet since the + standalone /app/timesheet route was retired. ──────────────────── */} + {tab === 'me' && meView === 'timesheet' && } {/* ── Team / Timesheet toggle (admins, non-personal teams only) ───── */} {tab === 'team' && canViewTimesheet && ( @@ -450,30 +483,6 @@ export const DashboardPage: React.FC = () => { )} - {/* ── Active session banner ───────────────────────────────────────── */} - {activeClockEvent && ( - - - - - Session Active - - - - {formatTimer(Math.floor((currentTime - activeClockEvent.startTime) / 1000))} elapsed - - - - )} - {/* ── Quick stats ─────────────────────────────────────────────────── */}
    {/* Hours today */} @@ -487,9 +496,11 @@ export const DashboardPage: React.FC = () => { Hours today - {( - (tab === 'me' ? (myStatus?.todaySeconds ?? 0) : todayTotalSeconds) / 3600 - ).toFixed(1)} + {formatDuration( + roundDurationSecondsForDisplay( + tab === 'me' ? (myStatus?.todaySeconds ?? 0) : todayTotalSeconds, + ), + )} {tab === 'me' ? ( myStatus?.isClockedIn && ( @@ -748,7 +759,7 @@ export const DashboardPage: React.FC = () => { Time logged today - {(todayTotalSeconds / 3600).toFixed(1)}h total + {formatDuration(roundDurationSecondsForDisplay(todayTotalSeconds))} total @@ -804,7 +815,7 @@ export const DashboardPage: React.FC = () => { {/* ── Recent activity β€” everyone's plan/wrap-up posts (teams only, not the personal workspace, which has no "everyone") ──────── */} - {!selectedTeam?.isPersonal && ( + {!isPersonalWorkspace && ( diff --git a/src/lib/TeamContext.test.tsx b/src/lib/TeamContext.test.tsx index 55c415be..c408ce40 100644 --- a/src/lib/TeamContext.test.tsx +++ b/src/lib/TeamContext.test.tsx @@ -41,6 +41,7 @@ vi.mock('./ddp', () => ({ getDdpClient: () => ({ docs: () => [], onCollectionChange: () => () => {}, + onDisconnect: () => () => {}, subscribe: (_name: string, _params: unknown[], cb: () => void) => { cb(); return () => {}; diff --git a/src/lib/api.ts b/src/lib/api.ts index c30e068e..d1c508e9 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1371,9 +1371,24 @@ export interface TeamRunningTimer { startTime: number; } +/** + * Midnight this morning in the viewer's own timezone. The dashboard's "today" + * has to line up with the timesheet's, which builds its ranges locally, so the + * boundary is decided here and sent to the server rather than derived from the + * server's clock. + */ +function localDayStartMs(): number { + const d = new Date(); + d.setHours(0, 0, 0, 0); + return d.getTime(); +} + export const teamDashboardApi = { getTeamClockStatus: (teamId: string) => - wormholeCall<{ members: TeamMemberClockStatus[] }>('clock.teamStatus', { teamId }).then((r) => + wormholeCall<{ members: TeamMemberClockStatus[] }>('clock.teamStatus', { + teamId, + dayStartMs: localDayStartMs(), + }).then((r) => r.members.map((m) => m.image && !/^https?:\/\//i.test(m.image) ? { ...m, image: `${TIMECORE_BASE_URL}${m.image.startsWith('/') ? '' : '/'}${m.image}` } diff --git a/src/lib/useClockToggle.ts b/src/lib/useClockToggle.ts index 1177c25e..fe2738ba 100644 --- a/src/lib/useClockToggle.ts +++ b/src/lib/useClockToggle.ts @@ -10,6 +10,7 @@ import { useCallback, useState } from 'react'; import { ApiError, clockApi } from './api'; +import { useClockBreak } from './useClockBreak'; import { useSessionPost } from './useSessionPost'; import { useTeam } from './TeamContext'; @@ -18,7 +19,8 @@ export function useClockToggle() { const [clockInLoading, setClockInLoading] = useState(false); const [clockOutLoading, setClockOutLoading] = useState(false); - const [clockPauseLoading, setClockPauseLoading] = useState(false); + // Break handling lives in its own hook so lighter surfaces can use it alone. + const { pauseClock, resumeClock, clockPauseLoading } = useClockBreak(); // Set when clock-out is refused by the plan-first gate ('plan-required'); // pages render it inline with a link to Huddle instead of an alert. const [clockOutBlockedReason, setClockOutBlockedReason] = useState(null); @@ -88,42 +90,6 @@ export function useClockToggle() { } }, [activeClockEvent, selectedTeamId, refetchClock]); - const pauseClock = useCallback(async () => { - const teamId = activeClockEvent?.teamId ?? selectedTeamId; - if (!teamId) return; - setClockPauseLoading(true); - try { - await clockApi.pause(teamId); - await refetchClock(); - // Notify all timer-displaying pages to refetch immediately - window.dispatchEvent(new CustomEvent('work:refetch')); - window.dispatchEvent(new CustomEvent('tickets:refetch')); - } catch (err) { - window.alert(err instanceof Error ? err.message : 'Failed to pause clock. Please try again.'); - } finally { - setClockPauseLoading(false); - } - }, [activeClockEvent, selectedTeamId, refetchClock]); - - const resumeClock = useCallback(async () => { - const teamId = activeClockEvent?.teamId ?? selectedTeamId; - if (!teamId) return; - setClockPauseLoading(true); - try { - await clockApi.resume(teamId); - await refetchClock(); - // Notify all timer-displaying pages to refetch immediately - window.dispatchEvent(new CustomEvent('work:refetch')); - window.dispatchEvent(new CustomEvent('tickets:refetch')); - } catch (err) { - window.alert( - err instanceof Error ? err.message : 'Failed to resume clock. Please try again.', - ); - } finally { - setClockPauseLoading(false); - } - }, [activeClockEvent, selectedTeamId, refetchClock]); - return { isClockedIn, clockIn, diff --git a/src/ui/BottomNav.tsx b/src/ui/BottomNav.tsx index 87452b51..2e0e0c01 100644 --- a/src/ui/BottomNav.tsx +++ b/src/ui/BottomNav.tsx @@ -4,16 +4,16 @@ * Visible only on small screens (md:hidden). * Five tabs: Dashboard, Huddle, Clock In/Out (center FAB), Tickets, More. * "More" opens a sheet with the remaining sidebar destinations (Teams, - * Organization, Timesheet, Work, Media Library, Messages, Notifications, - * Activity Log, Profile, Settings) so every sidebar link stays reachable - * on mobile without a hamburger drawer. + * Organization, Work, Media Library, Messages, Activity Log, Profile, + * Settings) so every sidebar link stays reachable on mobile without a + * hamburger drawer. Notifications is not among them β€” the header's bell + * icon is present at every width. * 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 { - faBell, faBug, faBuilding, faChevronLeft, @@ -31,7 +31,6 @@ import { faPhotoFilm, faSitemap, faStopwatch, - faTable, faUsers, faWrench, faXmark, @@ -77,11 +76,9 @@ const MORE_ITEMS: MoreItem[] = [ { icon: faUsers, label: 'Teams', href: '/app/teams' }, { icon: faSitemap, label: 'Organization', href: '/app/organization' }, { icon: faCircleUser, label: 'Profile', href: '/app/settings' }, - { icon: faTable, label: 'Timesheet', href: '/app/timesheet' }, { icon: faStopwatch, label: 'Work', href: '/app/work' }, { icon: faPhotoFilm, label: 'Media Library', href: '/app/media' }, { icon: faEnvelope, label: 'Messages', href: '/app/messages' }, - { icon: faBell, label: 'Notifications', href: '/app/notifications' }, { icon: faClockRotateLeft, label: 'Activity Log', href: '/app/activity' }, { icon: faGear, label: 'Settings', href: '/app/settings' }, ]; diff --git a/src/ui/CommandPalette.tsx b/src/ui/CommandPalette.tsx index f7621593..066aae04 100644 --- a/src/ui/CommandPalette.tsx +++ b/src/ui/CommandPalette.tsx @@ -24,7 +24,6 @@ import { faSpinner, faStopwatch, faSun, - faTable, faTicket, faTriangleExclamation, faUser, @@ -67,7 +66,6 @@ const NAV_ITEMS: NavSection[] = [ { icon: faGauge, label: 'Dashboard', href: '/app/dashboard', keywords: ['home', 'overview'] }, { icon: faStopwatch, label: 'Work', href: '/app/work', keywords: ['timer', 'tracking'] }, { icon: faListCheck, label: 'Tickets', href: '/app/tickets', keywords: ['tasks', 'issues'] }, - { icon: faTable, label: 'Timesheet', href: '/app/timesheet', keywords: ['hours', 'log'] }, ], }, { diff --git a/src/ui/Sidebar.tsx b/src/ui/Sidebar.tsx index 27a87b69..c75c0edb 100644 --- a/src/ui/Sidebar.tsx +++ b/src/ui/Sidebar.tsx @@ -22,7 +22,6 @@ import { faPhotoFilm, faSitemap, faStopwatch, - faTable, faUsers, faClockRotateLeft, } from '@fortawesome/free-solid-svg-icons'; @@ -72,7 +71,6 @@ const NAV: NavSection[] = [ { icon: faComments, label: 'Huddle', href: '/app/huddle' }, { icon: faClock, label: 'Clock', href: '/app/clock' }, { icon: faListCheck, label: 'Tickets', href: '/app/tickets' }, - { icon: faTable, label: 'Timesheet', href: '/app/timesheet' }, { icon: faStopwatch, label: 'Work', href: '/app/work' }, ], }, @@ -83,7 +81,6 @@ const NAV: NavSection[] = [ { icon: faSitemap, label: 'Organization', href: '/app/organization' }, { icon: faPhotoFilm, label: 'Media Library', href: '/app/media' }, { icon: faEnvelope, label: 'Messages', href: '/app/messages' }, - { icon: faBell, label: 'Notifications', href: '/app/notifications' }, { icon: faClockRotateLeft, label: 'Activity Log', href: '/app/activity' }, ], }, From 974ec5aaf2f2ba55f342e77f8e67ee19866c87ab Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 5 Aug 2026 19:45:38 -0400 Subject: [PATCH 59/66] Redesign: update backend clock, e2e tests, eslint config, vendor/ui --- .gitignore | 1 + eslint.config.mjs | 2 ++ meteor-backend/server/clock.js | 29 +++++++++++++++++++++------ tests/e2e/dashboard/dashboard.spec.ts | 14 ++++++++----- tests/e2e/pages/NotificationsPage.ts | 5 +++-- tests/e2e/pages/TimesheetPage.ts | 19 +++++++++++++----- tests/e2e/realtime/timesheet.spec.ts | 7 ++++--- tests/e2e/timesheet/timesheet.spec.ts | 15 +++++++------- vendor/ui | 2 +- 9 files changed, 65 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index 7a3396d7..9bc4fd09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules +.pnpm-store .DS_Store *.log packages/*/node_modules diff --git a/eslint.config.mjs b/eslint.config.mjs index 30aee36c..0a232c99 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -19,6 +19,8 @@ export default [ 'backend/data/videos', 'backend/data/videos/**', 'node_modules', + // pnpm's local content-addressable store β€” third-party package sources + '.pnpm-store', '**/scheduler.worker.js', '_build', 'build', diff --git a/meteor-backend/server/clock.js b/meteor-backend/server/clock.js index 5ffb8850..5d7beb4a 100644 --- a/meteor-backend/server/clock.js +++ b/meteor-backend/server/clock.js @@ -627,8 +627,11 @@ Meteor.methods({ return { ok: true }; }, - /** Team-wide clock status: all member clock states + today's hours. */ - async 'clock.teamStatus'({ teamId } = {}) { + /** + * Team-wide clock status: all member clock states + today's hours. + * `dayStartMs` is the caller's local midnight β€” see the boundary note below. + */ + async 'clock.teamStatus'({ teamId, dayStartMs } = {}) { try { console.log('[clock.teamStatus] called with teamId:', teamId); const identity = await requireIdentity(this); @@ -645,12 +648,26 @@ Meteor.methods({ throw new Meteor.Error('forbidden', 'Forbidden'); } - // Get today's start (UTC midnight) - const todayStart = new Date(); - todayStart.setUTCHours(0, 0, 0, 0); - const todayStartMs = todayStart.getTime(); const now = Date.now(); + // "Today" means the caller's local day, not the server's UTC day. The + // timesheet builds its ranges from local midnight (see getDateRange in + // features/clock/timesheetUtils), so a UTC boundary here made the two + // disagree for every client west of UTC: at UTC-4, UTC midnight is 8pm the + // previous evening, and last night's sessions got counted as today's hours. + // The client sends its own local midnight; ignore an absent or implausible + // value and fall back to the old UTC behaviour. + const utcMidnight = new Date(); + utcMidnight.setUTCHours(0, 0, 0, 0); + const oldestAcceptedDayStart = now - 48 * 60 * 60 * 1000; + const todayStartMs = + typeof dayStartMs === 'number' && + Number.isFinite(dayStartMs) && + dayStartMs <= now && + dayStartMs >= oldestAcceptedDayStart + ? dayStartMs + : utcMidnight.getTime(); + // Get today's clock events for all members const clockEvents = await ClockEvents.find({ userId: { $in: allMemberIds }, diff --git a/tests/e2e/dashboard/dashboard.spec.ts b/tests/e2e/dashboard/dashboard.spec.ts index c314138b..260ba399 100644 --- a/tests/e2e/dashboard/dashboard.spec.ts +++ b/tests/e2e/dashboard/dashboard.spec.ts @@ -36,16 +36,17 @@ test.describe('Dashboard', () => { // 7. All sidebar navigation items exist (scope to the sidebar nav β€” // the dashboard body also has Timesheet/Team-view tab buttons which - // otherwise create strict-mode selector collisions). + // otherwise create strict-mode selector collisions). Timesheet and + // Notifications are no longer among them: Timesheet lives in the + // dashboard body, Notifications is the header's bell icon. const nav = page.getByRole('navigation', { name: 'Main navigation' }); await expect(nav.getByRole('button', { name: /^Dashboard$/i })).toBeVisible(); await expect(nav.getByRole('button', { name: /^Tickets$/i })).toBeVisible(); - await expect(nav.getByRole('button', { name: /^Timesheet$/i })).toBeVisible(); await expect(nav.getByRole('button', { name: /^Teams$/i })).toBeVisible(); await expect(nav.getByRole('button', { name: /^Organization$/i })).toBeVisible(); - await expect(nav.getByRole('button', { name: /^Notifications$/i })).toBeVisible(); await expect(nav.getByRole('button', { name: /^Activity Log$/i })).toBeVisible(); await expect(nav.getByRole('button', { name: /^Clock$/i })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Notifications' })).toBeVisible(); }); test('active session button navigates to clock page', async ({ page }) => { @@ -63,9 +64,12 @@ test.describe('Dashboard', () => { await page.waitForLoadState('networkidle'); await page.waitForTimeout(2000); - // The dashboard shows "Session Active" alert with a "View" button. + // The running shift now shows inside the greeting banner rather than as a + // standalone alert: a "Session active" label, the elapsed time, and "View". // Allow extra time β€” the dashboard polls for clock state which can be slow. - await expect(page.getByText('Session Active')).toBeVisible({ timeout: 20000 }); + await expect(page.getByText('Session active', { exact: true })).toBeVisible({ + timeout: 20000, + }); const viewButton = page.getByRole('button', { name: 'View', exact: true }); await expect(viewButton).toBeVisible(); diff --git a/tests/e2e/pages/NotificationsPage.ts b/tests/e2e/pages/NotificationsPage.ts index e9846816..d5da6f9c 100644 --- a/tests/e2e/pages/NotificationsPage.ts +++ b/tests/e2e/pages/NotificationsPage.ts @@ -23,8 +23,9 @@ export class NotificationsPage extends BasePage { await this.heading.waitFor({ state: 'visible', timeout }); } - async navigateFromSidebar() { - await this.page.getByRole('button', { name: /^Notifications$/i }).click(); + /** The sidebar entry was removed β€” the header bell is the only nav path now. */ + async navigateFromHeader() { + await this.page.getByRole('button', { name: 'Notifications' }).click(); await this.waitForLoad(); } diff --git a/tests/e2e/pages/TimesheetPage.ts b/tests/e2e/pages/TimesheetPage.ts index 119b541c..577cca61 100644 --- a/tests/e2e/pages/TimesheetPage.ts +++ b/tests/e2e/pages/TimesheetPage.ts @@ -2,7 +2,11 @@ import { type Page, type Locator } from '@playwright/test'; import { BasePage } from './BasePage'; /** - * TimesheetPage - Page object for the personal timesheet + * TimesheetPage - Page object for the personal timesheet. + * + * The standalone /app/timesheet route was retired; the personal timesheet now + * lives at Dashboard -> Me -> Timesheet. The old URL still redirects there, so + * goto() keeps using it as a check that the redirect holds. */ export class TimesheetPage extends BasePage { readonly heading: Locator; @@ -15,7 +19,9 @@ export class TimesheetPage extends BasePage { constructor(page: Page) { super(page); - this.heading = this.page.getByRole('heading', { level: 1, name: /Timesheet/i }); + // The page heading is the dashboard's now, so the panel is identified by + // its Add Entry button β€” unique to the personal timesheet. + this.heading = this.page.getByRole('heading', { level: 1, name: /Dashboard/i }); this.addEntryButton = this.page.getByRole('button', { name: 'Add Entry' }); this.totalHours = this.page.getByText('Total Hours').locator('..'); this.breakHours = this.page.getByText('Break Hours').locator('..'); @@ -30,11 +36,14 @@ export class TimesheetPage extends BasePage { } async waitForLoad(timeout = 10000) { - await this.heading.waitFor({ state: 'visible', timeout }); + await this.addEntryButton.waitFor({ state: 'visible', timeout }); } - async navigateFromSidebar() { - await this.page.getByRole('button', { name: /^Timesheet$/i }).click(); + /** Open the timesheet from the dashboard's own Me -> Timesheet toggle. */ + async navigateFromDashboard() { + await this.page.goto('/app/dashboard'); + await this.heading.waitFor({ state: 'visible', timeout: 10000 }); + await this.page.locator('main').getByRole('button', { name: 'Timesheet', exact: true }).click(); await this.waitForLoad(); } diff --git a/tests/e2e/realtime/timesheet.spec.ts b/tests/e2e/realtime/timesheet.spec.ts index f6422432..d7a3e974 100644 --- a/tests/e2e/realtime/timesheet.spec.ts +++ b/tests/e2e/realtime/timesheet.spec.ts @@ -48,9 +48,10 @@ test.describe('Timesheet Real-time Sync', () => { await page1.waitForLoadState('networkidle'); await page2.waitForLoadState('networkidle'); - // Both sessions should show the Timesheet heading - await expect(page1.getByRole('heading', { level: 1, name: /Timesheet/i })).toBeVisible(); - await expect(page2.getByRole('heading', { level: 1, name: /Timesheet/i })).toBeVisible(); + // /app/timesheet redirects to Dashboard -> Me -> Timesheet, so both + // sessions land on the dashboard showing the personal timesheet panel. + await expect(page1.getByRole('button', { name: 'Add Entry' })).toBeVisible(); + await expect(page2.getByRole('button', { name: 'Add Entry' })).toBeVisible(); }); test('clock page shows same state in both sessions', async () => { diff --git a/tests/e2e/timesheet/timesheet.spec.ts b/tests/e2e/timesheet/timesheet.spec.ts index 51352324..437c56f3 100644 --- a/tests/e2e/timesheet/timesheet.spec.ts +++ b/tests/e2e/timesheet/timesheet.spec.ts @@ -17,10 +17,11 @@ test.describe('Timesheet', () => { test('should display all summary grids', async ({ page }) => { await page.goto('/app/timesheet'); - await page.getByRole('heading', { level: 1, name: 'Timesheet' }).waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Add Entry' }).waitFor({ state: 'visible' }); - // Verify correct URL - expect(page.url()).toContain('/app/timesheet'); + // The standalone route was retired β€” the old URL redirects to the + // dashboard's Me -> Timesheet view. + expect(page.url()).toContain('/app/dashboard'); // Verify all summary stat cards are visible await expect(page.getByText('Total Hours')).toBeVisible(); @@ -32,7 +33,7 @@ test.describe('Timesheet', () => { test('all date filter presets work', async ({ page }) => { await page.goto('/app/timesheet'); - await page.getByRole('heading', { level: 1, name: 'Timesheet' }).waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Add Entry' }).waitFor({ state: 'visible' }); // Verify all preset buttons exist const presets = ['Today', 'Yesterday', 'Last Week', 'This Week', '14 Days', 'Custom']; @@ -57,7 +58,7 @@ test.describe('Timesheet', () => { test('Add Entry button should be enabled when user has teams', async ({ page }) => { await page.goto('/app/timesheet'); - await page.getByRole('heading', { level: 1, name: 'Timesheet' }).waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Add Entry' }).waitFor({ state: 'visible' }); await page.waitForTimeout(2000); // Verify Add Entry button exists and is enabled @@ -68,7 +69,7 @@ test.describe('Timesheet', () => { test('should be able to add an entry from timesheet', async ({ page }) => { await page.goto('/app/timesheet'); - await page.getByRole('heading', { level: 1, name: 'Timesheet' }).waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Add Entry' }).waitFor({ state: 'visible' }); await page.waitForTimeout(2000); // Click Add Entry @@ -133,7 +134,7 @@ test.describe('Timesheet', () => { // Navigate to timesheet await page.goto('/app/timesheet'); - await page.getByRole('heading', { level: 1, name: 'Timesheet' }).waitFor({ state: 'visible' }); + await page.getByRole('button', { name: 'Add Entry' }).waitFor({ state: 'visible' }); await page.waitForTimeout(2000); // Select "Today" to see the session diff --git a/vendor/ui b/vendor/ui index e159b7b0..a5887a2a 160000 --- a/vendor/ui +++ b/vendor/ui @@ -1 +1 @@ -Subproject commit e159b7b079d2ef9f379c28b37b78a252b03ccd01 +Subproject commit a5887a2a70907189419ff268ad6d2266442c16d3 From 31e732b82d5502be536a0e732f54a84f9101349b Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 5 Aug 2026 21:05:16 -0400 Subject: [PATCH 60/66] Fix reviewer feedback: notification deep-links, cross-org deep-link, BottomNav focus trap, styles.css comment - TeamsPage: forward old /app/teams?tab=timesheet notification URLs to /app/dashboard - TeamContext: expose allTeams (unscoped) alongside scoped teams - DashboardPage: switch org when deep-link teamId belongs to a different org - BottomNav More sheet: move focus in on open, trap Tab/Shift+Tab, restore focus to trigger on close - styles.css: document data-slot/data-state internals dependency with TODO to fix upstream --- src/features/dashboard/DashboardPage.tsx | 16 +++++++++-- src/features/teams/TeamsPage.tsx | 15 +++++++++- src/lib/TeamContext.tsx | 5 ++++ src/lib/useClockToggle.test.ts | 1 + src/styles.css | 5 +++- src/ui/BottomNav.tsx | 36 +++++++++++++++++++++++- 6 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index 3c4a66f2..b767d57f 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -68,11 +68,13 @@ export const DashboardPage: React.FC = () => { const { navigate } = useRouter(); const { teams, + allTeams, teamsReady, activeClockEvent, currentTime, selectedTeamId, setSelectedTeamId, + setSelectedOrgId, isAdmin, } = useTeam(); @@ -135,12 +137,22 @@ export const DashboardPage: React.FC = () => { setMeView('timesheet'); } if (memberId) setInitialMemberId(memberId); - if (teamId && teams.some((t) => t.id === teamId)) setSelectedTeamId(teamId); + if (teamId) { + const inScope = teams.find((t) => t.id === teamId); + const crossOrg = !inScope && allTeams.find((t) => t.id === teamId); + if (inScope) { + setSelectedTeamId(teamId); + } else if (crossOrg) { + // Team is in a different org β€” switch org first so the team becomes visible + setSelectedOrgId(crossOrg.orgId); + setSelectedTeamId(teamId); + } + } if (deepTab || deepView || memberId || teamId) { window.history.replaceState(null, '', window.location.pathname); } - }, [teamsReady, teams, setSelectedTeamId]); + }, [teamsReady, teams, allTeams, setSelectedTeamId, setSelectedOrgId]); // Members list (needed by the admin Timesheet view only) useEffect(() => { diff --git a/src/features/teams/TeamsPage.tsx b/src/features/teams/TeamsPage.tsx index 8d27dc49..3b2a4aab 100644 --- a/src/features/teams/TeamsPage.tsx +++ b/src/features/teams/TeamsPage.tsx @@ -121,6 +121,19 @@ export const TeamsPage: React.FC = () => { const teamId = params.get('teamId'); const hasQuery = window.location.search.length > 0; + // Old notification URLs pointed here with tab=timesheet; the timesheet + // view has moved to the Dashboard. Forward so those links still work. + if (params.get('tab') === 'timesheet') { + const fwd = new URLSearchParams(); + fwd.set('tab', 'timesheet'); + const memberId = params.get('memberId'); + const fwdTeamId = params.get('teamId'); + if (memberId) fwd.set('memberId', memberId); + if (fwdTeamId) fwd.set('teamId', fwdTeamId); + navigate(`/app/dashboard?${fwd.toString()}`); + return; + } + if (teamId && teams.some((t) => t.id === teamId)) setSelectedTeamId(teamId); // Clean up query params from URL without triggering a navigation @@ -128,7 +141,7 @@ export const TeamsPage: React.FC = () => { const cleanUrl = window.location.pathname; window.history.replaceState(null, '', cleanUrl); } - }, [pathname, urlCheckCounter, setSelectedTeamId, teams, teamsReady]); + }, [pathname, urlCheckCounter, setSelectedTeamId, navigate, teams, teamsReady]); // ── Listen for navigation events (from navigate()) ── useEffect(() => { diff --git a/src/lib/TeamContext.tsx b/src/lib/TeamContext.tsx index 35c59844..f35302f7 100644 --- a/src/lib/TeamContext.tsx +++ b/src/lib/TeamContext.tsx @@ -60,6 +60,8 @@ type EnterpriseSummary = { export interface TeamContextValue { teams: Team[]; + /** All teams the user belongs to, across every org (not scoped to selectedOrgId). */ + allTeams: Team[]; pendingRequests: TeamJoinRequest[]; enterprises: EnterpriseSummary[]; organizations: Array<{ @@ -91,6 +93,7 @@ export interface TeamContextValue { const TeamCtx = createContext({ pendingRequests: [], teams: [], + allTeams: [], enterprises: [], organizations: [], teamsReady: false, @@ -454,6 +457,7 @@ export const TeamProvider: React.FC<{ children: React.ReactNode }> = ({ children const value = useMemo( () => ({ teams: scopedTeams, + allTeams: teams, pendingRequests, enterprises, organizations, @@ -476,6 +480,7 @@ export const TeamProvider: React.FC<{ children: React.ReactNode }> = ({ children }), [ scopedTeams, + teams, pendingRequests, enterprises, organizations, diff --git a/src/lib/useClockToggle.test.ts b/src/lib/useClockToggle.test.ts index 921baab2..61a34c0d 100644 --- a/src/lib/useClockToggle.test.ts +++ b/src/lib/useClockToggle.test.ts @@ -45,6 +45,7 @@ function setupTeam( ) { mockUseTeam.mockReturnValue({ teams: [], + allTeams: [], pendingRequests: [], enterprises: [], organizations: [], diff --git a/src/styles.css b/src/styles.css index 6fc00408..8649ea61 100644 --- a/src/styles.css +++ b/src/styles.css @@ -105,7 +105,10 @@ html[data-theme='dark'] { /* All modals: centered popup on every screen size (override the library's * full-screen-on-mobile defaults: `min-h-dvh`, `rounded-none`, `max-h-dvh`). * The org-switcher bottom-sheet overrides below keep higher specificity and - * continue to win inside the 767px media query. */ + * continue to win inside the 767px media query. + * NOTE: These selectors target @mieweb/ui internals (data-slot, data-state). + * A library upgrade that renames those attributes will break all modals. + * TODO: Replace with a proper Modal size/variant prop in @mieweb/ui instead. */ [data-slot='modal'] { min-height: 0 !important; border-radius: 0.75rem !important; diff --git a/src/ui/BottomNav.tsx b/src/ui/BottomNav.tsx index 2e0e0c01..f2a4a1cd 100644 --- a/src/ui/BottomNav.tsx +++ b/src/ui/BottomNav.tsx @@ -102,13 +102,44 @@ export const BottomNav: React.FC = () => { const { openFeedback, openReportIssue } = useAppFeedback(); const [moreOpen, setMoreOpen] = useState(false); const [moreSection, setMoreSection] = useState('root'); + const moreButtonRef = React.useRef(null); + const moreDialogRef = React.useRef(null); React.useEffect(() => { if (!moreOpen) return undefined; const original = document.body.style.overflow; document.body.style.overflow = 'hidden'; + + // Move focus into the first interactive element in the sheet + const dialog = moreDialogRef.current; + const firstFocusable = dialog?.querySelector( + 'button:not([disabled]), [href], input:not([disabled])', + ); + firstFocusable?.focus(); + const onKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') setMoreOpen(false); + if (e.key === 'Escape') { + setMoreOpen(false); + setMoreSection('root'); + moreButtonRef.current?.focus(); + return; + } + if (e.key !== 'Tab' || !dialog) return; + const focusable = Array.from( + dialog.querySelectorAll( + 'button:not([disabled]), [href], input:not([disabled]), [tabindex]:not([tabindex="-1"])', + ), + ); + if (focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } }; document.addEventListener('keydown', onKeyDown); return () => { @@ -124,6 +155,7 @@ export const BottomNav: React.FC = () => { const closeMore = () => { setMoreOpen(false); setMoreSection('root'); + moreButtonRef.current?.focus(); }; const goToMoreItem = (href: string) => { closeMore(); @@ -234,6 +266,7 @@ export const BottomNav: React.FC = () => { return ( - -
    - ) - } + > { } /> - {/* ── Me / Timesheet toggle ─────────────────────────────────────── */} - {tab === 'me' && ( -
    - - -
    - )} - - {/* ── Me Timesheet view β€” the app's only personal timesheet since the - standalone /app/timesheet route was retired. ──────────────────── */} - {tab === 'me' && meView === 'timesheet' && } - - {/* ── Team / Timesheet toggle (admins, non-personal teams only) ───── */} - {tab === 'team' && canViewTimesheet && ( -
    - - -
    - )} + {/* ── Overview / Timesheet toggle ──────────────────────────────── */} +
    + + +
    - {/* ── Timesheet view (admins only) β€” replaces the overview below ──── */} - {tab === 'team' && canViewTimesheet && teamView === 'timesheet' && selectedTeamId && ( - + {/* ── Timesheet view: admin panel for admins, personal panel for everyone else ── */} + {view === 'timesheet' && ( + canViewTimesheet && selectedTeamId ? ( + + ) : ( + + ) )} - {(tab === 'me' ? meView === 'overview' : teamView === 'overview') && ( + {view === 'overview' && ( <> {/* ── First-time welcome ──────────────────────────────────────────── */} {isFirstTime && ( @@ -508,34 +389,16 @@ export const DashboardPage: React.FC = () => { Hours today - {formatDuration( - roundDurationSecondsForDisplay( - tab === 'me' ? (myStatus?.todaySeconds ?? 0) : todayTotalSeconds, - ), - )} + {formatDuration(roundDurationSecondsForDisplay(todayTotalSeconds))} - {tab === 'me' ? ( - myStatus?.isClockedIn && ( - - ↑ clocked in - - ) - ) : ( - <> - {membersClocked.length > 0 && ( - - ↑ {membersClocked.length} active - - )} - + {membersClocked.length > 0 && ( + + ↑ {membersClocked.length} active + )}
    @@ -552,9 +415,9 @@ export const DashboardPage: React.FC = () => { Open tickets - {String(tab === 'me' ? myOpenTickets.length : openTickets.length)} + {String(openTickets.length)} - {tab === 'team' && unassignedOpen.length > 0 && ( + {unassignedOpen.length > 0 && ( {unassignedOpen.length} unassigned @@ -574,7 +437,7 @@ export const DashboardPage: React.FC = () => { Closed today - {String(tab === 'me' ? myClosedToday.length : closedToday.length)} + {String(closedToday.length)}
    @@ -591,17 +454,11 @@ export const DashboardPage: React.FC = () => { High priority - {String( - tab === 'me' - ? myHighPriority.filter((t) => t.status !== 'closed' && t.status !== 'done') - .length - : highPriority.filter((t) => t.status !== 'closed' && t.status !== 'done') - .length, - )} + {String(highPriority.filter((t) => t.status !== 'closed' && t.status !== 'done').length)} - {(tab === 'me' ? myOverdue.length : overdue.length) > 0 && ( + {overdue.length > 0 && ( - {tab === 'me' ? myOverdue.length : overdue.length} overdue + {overdue.length} overdue )}
    @@ -610,7 +467,7 @@ export const DashboardPage: React.FC = () => {
    {/* ── Team members ─────────────────────────────────────────────────── */} - {tab === 'team' && ( + {!isPersonalWorkspace && ( @@ -692,9 +549,9 @@ export const DashboardPage: React.FC = () => { Active tickets - {visibleRunningTimers.length > 0 && ( + {runningTimers.length > 0 && ( - {visibleRunningTimers.length} running + {runningTimers.length} running )} @@ -707,7 +564,7 @@ export const DashboardPage: React.FC = () => {
    - ) : visibleRunningTimers.length === 0 ? ( + ) : runningTimers.length === 0 ? (
    No active timers right now @@ -715,7 +572,7 @@ export const DashboardPage: React.FC = () => {
    ) : (
      - {visibleRunningTimers.map((timer) => { + {runningTimers.map((timer) => { const ticket = tickets.find((t) => t.id === timer.ticketId); const elapsedSec = Math.floor((currentTime - timer.startTime) / 1000); const priorityColor = @@ -725,36 +582,43 @@ export const DashboardPage: React.FC = () => { ? 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-400' : 'bg-neutral-100 text-neutral-600 dark:bg-neutral-800 dark:text-neutral-400'; return ( -
    • - {ticket?.priority && ( - - {ticket.priority === 'urgent' - ? 'High' - : ticket.priority.charAt(0).toUpperCase() + ticket.priority.slice(1)} - - )} -
      - - {timer.ticketTitle} - -
      - - - {timer.userName} +
    • +
    -
    - - {formatTimer(elapsedSec)} - -
    +
    + + {formatTimer(elapsedSec)} + +
    + ); })} @@ -764,7 +628,7 @@ export const DashboardPage: React.FC = () => { {/* ── Time logged today ────────────────────────────────────────────── */} - {tab === 'team' && memberStatuses.some((m) => m.todaySeconds > 0) && ( + {!isPersonalWorkspace && memberStatuses.some((m) => m.todaySeconds > 0) && ( From 9a2b9b92eaba828f74c667f21c946f2f4dd5b597 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 5 Aug 2026 21:19:24 -0400 Subject: [PATCH 62/66] Dashboard: restore Me/Team stats scoping as inline tag toggle above stats cards --- src/features/dashboard/DashboardPage.tsx | 109 ++++++++++++++++++----- 1 file changed, 88 insertions(+), 21 deletions(-) diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index dff18c64..f2ac8a88 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -88,6 +88,10 @@ export const DashboardPage: React.FC = () => { const [runningTimers, setRunningTimers] = useState([]); const [loading, setLoading] = useState(false); const [view, setView] = useState<'overview' | 'timesheet'>('overview'); + const [statsScope, setStatsScope] = useState<'me' | 'team'>(() => { + if (typeof window === 'undefined') return 'team'; + return localStorage.getItem('app:dashboardScope') === 'me' ? 'me' : 'team'; + }); const [initialMemberId, setInitialMemberId] = useState(''); const [teamMembers, setTeamMembers] = useState([]); @@ -233,6 +237,25 @@ export const DashboardPage: React.FC = () => { const todayTotalSeconds = memberStatuses.reduce((sum, m) => sum + m.todaySeconds, 0); const membersClocked = memberStatuses.filter((m) => m.isClockedIn); + const myStatus = memberStatuses.find((m) => m.userId === user?.id) ?? null; + const myTickets = tickets.filter((t) => user && t.assignedTo.includes(user.id)); + const myOpenTickets = myTickets.filter((t) => t.status !== 'closed' && t.status !== 'done'); + const myClosedToday = myTickets.filter((t) => { + if (t.status !== 'closed' && t.status !== 'done') return false; + if (!t.updatedAt) return false; + const updated = new Date(t.updatedAt); + const today = new Date(); + return ( + updated.getFullYear() === today.getFullYear() && + updated.getMonth() === today.getMonth() && + updated.getDate() === today.getDate() + ); + }); + const myHighPriority = myTickets.filter((t) => t.priority === 'high' || t.priority === 'urgent'); + const myOverdue = myHighPriority.filter((t) => t.status !== 'closed' && t.status !== 'done'); + const visibleRunningTimers = + statsScope === 'me' ? runningTimers.filter((t) => t.userId === user?.id) : runningTimers; + // Sort members: admins first, then clocked-in, then by hours const sortedMembers = [...memberStatuses].sort((a, b) => { const aIsAdmin = teamAdminIds.has(a.userId); @@ -376,8 +399,40 @@ export const DashboardPage: React.FC = () => { )} - {/* ── Quick stats ─────────────────────────────────────────────────── */} -
    + {/* ── Quick stats ─────────────────────────────────────────────────── */} {!isPersonalWorkspace && ( +
    + + +
    + )}
    {/* Hours today */} @@ -389,16 +444,24 @@ export const DashboardPage: React.FC = () => { Hours today - {formatDuration(roundDurationSecondsForDisplay(todayTotalSeconds))} + {formatDuration( + roundDurationSecondsForDisplay( + statsScope === 'me' ? (myStatus?.todaySeconds ?? 0) : todayTotalSeconds, + ), + )} - {membersClocked.length > 0 && ( - - ↑ {membersClocked.length} active - + {statsScope === 'me' ? ( + myStatus?.isClockedIn && ( + + ↑ clocked in + + ) + ) : ( + membersClocked.length > 0 && ( + + ↑ {membersClocked.length} active + + ) )}
    @@ -415,9 +478,9 @@ export const DashboardPage: React.FC = () => { Open tickets - {String(openTickets.length)} + {String(statsScope === 'me' ? myOpenTickets.length : openTickets.length)} - {unassignedOpen.length > 0 && ( + {statsScope === 'team' && unassignedOpen.length > 0 && ( {unassignedOpen.length} unassigned @@ -437,7 +500,7 @@ export const DashboardPage: React.FC = () => { Closed today - {String(closedToday.length)} + {String(statsScope === 'me' ? myClosedToday.length : closedToday.length)}
    @@ -454,11 +517,15 @@ export const DashboardPage: React.FC = () => { High priority - {String(highPriority.filter((t) => t.status !== 'closed' && t.status !== 'done').length)} + {String( + (statsScope === 'me' ? myHighPriority : highPriority).filter( + (t) => t.status !== 'closed' && t.status !== 'done', + ).length, + )} - {overdue.length > 0 && ( + {(statsScope === 'me' ? myOverdue : overdue).length > 0 && ( - {overdue.length} overdue + {(statsScope === 'me' ? myOverdue : overdue).length} overdue )}
    @@ -549,9 +616,9 @@ export const DashboardPage: React.FC = () => { Active tickets - {runningTimers.length > 0 && ( + {visibleRunningTimers.length > 0 && ( - {runningTimers.length} running + {visibleRunningTimers.length} running )} @@ -564,7 +631,7 @@ export const DashboardPage: React.FC = () => {
    - ) : runningTimers.length === 0 ? ( + ) : visibleRunningTimers.length === 0 ? (
    No active timers right now @@ -572,7 +639,7 @@ export const DashboardPage: React.FC = () => {
    ) : (
      - {runningTimers.map((timer) => { + {visibleRunningTimers.map((timer) => { const ticket = tickets.find((t) => t.id === timer.ticketId); const elapsedSec = Math.floor((currentTime - timer.startTime) / 1000); const priorityColor = From 3cfa9da42843808b64e1499a034a38b59a771016 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 5 Aug 2026 21:22:41 -0400 Subject: [PATCH 63/66] Dashboard: restore Me/Team toggle + scoped stats, keep unified Overview/Timesheet - Me/Team pill toggle back in the title bar - Stats cards (Hours, Open tickets, Closed today, High priority) scope to Me or Team - Team Members and Time logged today sections only appear on Team tab - Single Overview/Timesheet toggle in content (no duplicate toggles) - Active ticket rows remain clickable, navigating to /app/tickets/:id --- src/features/dashboard/DashboardPage.tsx | 119 +++++++++++++---------- 1 file changed, 65 insertions(+), 54 deletions(-) diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index f2ac8a88..e88ebbdc 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -87,11 +87,19 @@ export const DashboardPage: React.FC = () => { const [memberStatuses, setMemberStatuses] = useState([]); const [runningTimers, setRunningTimers] = useState([]); const [loading, setLoading] = useState(false); - const [view, setView] = useState<'overview' | 'timesheet'>('overview'); - const [statsScope, setStatsScope] = useState<'me' | 'team'>(() => { - if (typeof window === 'undefined') return 'team'; - return localStorage.getItem('app:dashboardScope') === 'me' ? 'me' : 'team'; + + const [storedTab, _setTab] = useState<'me' | 'team'>(() => { + if (typeof window === 'undefined') return 'me'; + return localStorage.getItem('app:dashboardTab') === 'team' ? 'team' : 'me'; }); + const setTab = useCallback((next: 'me' | 'team') => { + _setTab(next); + if (typeof window !== 'undefined') localStorage.setItem('app:dashboardTab', next); + }, []); + // Personal workspaces hide the Me/Team toggle β€” both views would be identical. + const tab = isPersonalWorkspace ? 'me' : storedTab; + + const [view, setView] = useState<'overview' | 'timesheet'>('overview'); const [initialMemberId, setInitialMemberId] = useState(''); const [teamMembers, setTeamMembers] = useState([]); @@ -106,7 +114,11 @@ export const DashboardPage: React.FC = () => { const memberId = params.get('memberId'); const teamId = params.get('teamId'); - if (deepTab === 'timesheet' || deepView === 'timesheet') { + if (deepTab === 'timesheet') { + setTab('team'); + setView('timesheet'); + } + if (deepView === 'timesheet') { setView('timesheet'); } if (memberId) setInitialMemberId(memberId); @@ -253,8 +265,8 @@ export const DashboardPage: React.FC = () => { }); const myHighPriority = myTickets.filter((t) => t.priority === 'high' || t.priority === 'urgent'); const myOverdue = myHighPriority.filter((t) => t.status !== 'closed' && t.status !== 'done'); - const visibleRunningTimers = - statsScope === 'me' ? runningTimers.filter((t) => t.userId === user?.id) : runningTimers; + const myRunningTimers = runningTimers.filter((t) => t.userId === user?.id); + const visibleRunningTimers = tab === 'me' ? myRunningTimers : runningTimers; // Sort members: admins first, then clocked-in, then by hours const sortedMembers = [...memberStatuses].sort((a, b) => { @@ -281,13 +293,44 @@ export const DashboardPage: React.FC = () => { return ( + + +
    + ) + } > { )} - {/* ── Quick stats ─────────────────────────────────────────────────── */} {!isPersonalWorkspace && ( -
    - - -
    - )}
    + {/* ── Quick stats ─────────────────────────────────────────────────── */} +
    {/* Hours today */} @@ -446,11 +457,11 @@ export const DashboardPage: React.FC = () => { {formatDuration( roundDurationSecondsForDisplay( - statsScope === 'me' ? (myStatus?.todaySeconds ?? 0) : todayTotalSeconds, + tab === 'me' ? (myStatus?.todaySeconds ?? 0) : todayTotalSeconds, ), )} - {statsScope === 'me' ? ( + {tab === 'me' ? ( myStatus?.isClockedIn && ( ↑ clocked in @@ -478,9 +489,9 @@ export const DashboardPage: React.FC = () => { Open tickets - {String(statsScope === 'me' ? myOpenTickets.length : openTickets.length)} + {String(tab === 'me' ? myOpenTickets.length : openTickets.length)} - {statsScope === 'team' && unassignedOpen.length > 0 && ( + {tab === 'team' && unassignedOpen.length > 0 && ( {unassignedOpen.length} unassigned @@ -500,7 +511,7 @@ export const DashboardPage: React.FC = () => { Closed today - {String(statsScope === 'me' ? myClosedToday.length : closedToday.length)} + {String(tab === 'me' ? myClosedToday.length : closedToday.length)}
    @@ -518,14 +529,14 @@ export const DashboardPage: React.FC = () => { {String( - (statsScope === 'me' ? myHighPriority : highPriority).filter( + (tab === 'me' ? myHighPriority : highPriority).filter( (t) => t.status !== 'closed' && t.status !== 'done', ).length, )} - {(statsScope === 'me' ? myOverdue : overdue).length > 0 && ( + {(tab === 'me' ? myOverdue : overdue).length > 0 && ( - {(statsScope === 'me' ? myOverdue : overdue).length} overdue + {(tab === 'me' ? myOverdue : overdue).length} overdue )}
    @@ -534,7 +545,7 @@ export const DashboardPage: React.FC = () => {
    {/* ── Team members ─────────────────────────────────────────────────── */} - {!isPersonalWorkspace && ( + {tab === 'team' && ( @@ -695,7 +706,7 @@ export const DashboardPage: React.FC = () => { {/* ── Time logged today ────────────────────────────────────────────── */} - {!isPersonalWorkspace && memberStatuses.some((m) => m.todaySeconds > 0) && ( + {tab === 'team' && memberStatuses.some((m) => m.todaySeconds > 0) && ( From eeec0fefc20190359b6db93e60c17a08db6136c0 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Wed, 5 Aug 2026 22:04:57 -0400 Subject: [PATCH 64/66] style: fix prettier formatting in DashboardPage --- src/features/dashboard/DashboardPage.tsx | 42 ++++++++++++++---------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index e88ebbdc..02b54fc3 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -390,8 +390,8 @@ export const DashboardPage: React.FC = () => {
    {/* ── Timesheet view: admin panel for admins, personal panel for everyone else ── */} - {view === 'timesheet' && ( - canViewTimesheet && selectedTeamId ? ( + {view === 'timesheet' && + (canViewTimesheet && selectedTeamId ? ( { /> ) : ( - ) - )} + ))} {view === 'overview' && ( <> @@ -461,19 +460,25 @@ export const DashboardPage: React.FC = () => { ), )} - {tab === 'me' ? ( - myStatus?.isClockedIn && ( - - ↑ clocked in - - ) - ) : ( - membersClocked.length > 0 && ( - - ↑ {membersClocked.length} active - - ) - )} + {tab === 'me' + ? myStatus?.isClockedIn && ( + + ↑ clocked in + + ) + : membersClocked.length > 0 && ( + + ↑ {membersClocked.length} active + + )}
    @@ -673,7 +678,8 @@ export const DashboardPage: React.FC = () => { > {ticket.priority === 'urgent' ? 'High' - : ticket.priority.charAt(0).toUpperCase() + ticket.priority.slice(1)} + : ticket.priority.charAt(0).toUpperCase() + + ticket.priority.slice(1)} )}
    From 513dcdba3450011277732c739fb7d8e2081272b3 Mon Sep 17 00:00:00 2001 From: Poonam Dharamkar Date: Fri, 7 Aug 2026 11:52:10 -0400 Subject: [PATCH 65/66] Dashboard: show personal timesheet in Me tab, admin timesheet only in Team MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Timesheet view picked its panel from canViewTimesheet alone, which only means "admin on a non-personal workspace" and says nothing about the active tab. Admins therefore got AdminTimesheetPanel β€” member picker, no Add Entry β€” in both Me and Team. Gate the admin panel on tab === 'team' so Me β†’ Timesheet is always the signed-in user's own timesheet, and scope the getMembers fetch to the same condition so the Me tab stops fetching a member list it doesn't render. Co-Authored-By: Claude Opus 5 (1M context) --- src/features/dashboard/DashboardPage.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/features/dashboard/DashboardPage.tsx b/src/features/dashboard/DashboardPage.tsx index 02b54fc3..69ff3e0b 100644 --- a/src/features/dashboard/DashboardPage.tsx +++ b/src/features/dashboard/DashboardPage.tsx @@ -98,6 +98,9 @@ export const DashboardPage: React.FC = () => { }, []); // Personal workspaces hide the Me/Team toggle β€” both views would be identical. const tab = isPersonalWorkspace ? 'me' : storedTab; + // The admin timesheet (member picker, everyone's entries) belongs to the Team + // tab only. Me β†’ Timesheet is always the signed-in user's own timesheet. + const showAdminTimesheet = canViewTimesheet && tab === 'team'; const [view, setView] = useState<'overview' | 'timesheet'>('overview'); const [initialMemberId, setInitialMemberId] = useState(''); @@ -141,7 +144,7 @@ export const DashboardPage: React.FC = () => { // Members list (needed by the admin Timesheet view only) useEffect(() => { - if (!selectedTeamId || !canViewTimesheet) { + if (!selectedTeamId || !showAdminTimesheet) { setTeamMembers([]); return; } @@ -157,7 +160,7 @@ export const DashboardPage: React.FC = () => { return () => { cancelled = true; }; - }, [selectedTeamId, canViewTimesheet]); + }, [selectedTeamId, showAdminTimesheet]); // ── Recent activity β€” everyone's published plan/wrap-up posts for this // team, live via the same DDP publication the Huddle feed uses. Clicking @@ -389,9 +392,9 @@ export const DashboardPage: React.FC = () => {
    - {/* ── Timesheet view: admin panel for admins, personal panel for everyone else ── */} + {/* ── Timesheet view: Team β†’ admin panel (admins only), Me β†’ personal panel ── */} {view === 'timesheet' && - (canViewTimesheet && selectedTeamId ? ( + (showAdminTimesheet && selectedTeamId ? ( Date: Fri, 7 Aug 2026 17:33:39 -0400 Subject: [PATCH 66/66] revert: back out UTC/local midnight fix for clock.teamStatus Will address the timezone boundary bug in a separate PR. --- meteor-backend/server/clock.js | 29 ++++++----------------------- src/lib/api.ts | 17 +---------------- 2 files changed, 7 insertions(+), 39 deletions(-) diff --git a/meteor-backend/server/clock.js b/meteor-backend/server/clock.js index 5d7beb4a..5ffb8850 100644 --- a/meteor-backend/server/clock.js +++ b/meteor-backend/server/clock.js @@ -627,11 +627,8 @@ Meteor.methods({ return { ok: true }; }, - /** - * Team-wide clock status: all member clock states + today's hours. - * `dayStartMs` is the caller's local midnight β€” see the boundary note below. - */ - async 'clock.teamStatus'({ teamId, dayStartMs } = {}) { + /** Team-wide clock status: all member clock states + today's hours. */ + async 'clock.teamStatus'({ teamId } = {}) { try { console.log('[clock.teamStatus] called with teamId:', teamId); const identity = await requireIdentity(this); @@ -648,26 +645,12 @@ Meteor.methods({ throw new Meteor.Error('forbidden', 'Forbidden'); } + // Get today's start (UTC midnight) + const todayStart = new Date(); + todayStart.setUTCHours(0, 0, 0, 0); + const todayStartMs = todayStart.getTime(); const now = Date.now(); - // "Today" means the caller's local day, not the server's UTC day. The - // timesheet builds its ranges from local midnight (see getDateRange in - // features/clock/timesheetUtils), so a UTC boundary here made the two - // disagree for every client west of UTC: at UTC-4, UTC midnight is 8pm the - // previous evening, and last night's sessions got counted as today's hours. - // The client sends its own local midnight; ignore an absent or implausible - // value and fall back to the old UTC behaviour. - const utcMidnight = new Date(); - utcMidnight.setUTCHours(0, 0, 0, 0); - const oldestAcceptedDayStart = now - 48 * 60 * 60 * 1000; - const todayStartMs = - typeof dayStartMs === 'number' && - Number.isFinite(dayStartMs) && - dayStartMs <= now && - dayStartMs >= oldestAcceptedDayStart - ? dayStartMs - : utcMidnight.getTime(); - // Get today's clock events for all members const clockEvents = await ClockEvents.find({ userId: { $in: allMemberIds }, diff --git a/src/lib/api.ts b/src/lib/api.ts index d1c508e9..c30e068e 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1371,24 +1371,9 @@ export interface TeamRunningTimer { startTime: number; } -/** - * Midnight this morning in the viewer's own timezone. The dashboard's "today" - * has to line up with the timesheet's, which builds its ranges locally, so the - * boundary is decided here and sent to the server rather than derived from the - * server's clock. - */ -function localDayStartMs(): number { - const d = new Date(); - d.setHours(0, 0, 0, 0); - return d.getTime(); -} - export const teamDashboardApi = { getTeamClockStatus: (teamId: string) => - wormholeCall<{ members: TeamMemberClockStatus[] }>('clock.teamStatus', { - teamId, - dayStartMs: localDayStartMs(), - }).then((r) => + wormholeCall<{ members: TeamMemberClockStatus[] }>('clock.teamStatus', { teamId }).then((r) => r.members.map((m) => m.image && !/^https?:\/\//i.test(m.image) ? { ...m, image: `${TIMECORE_BASE_URL}${m.image.startsWith('/') ? '' : '/'}${m.image}` }