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/docs/redesign-implementation-prompt.md b/docs/redesign-implementation-prompt.md new file mode 100644 index 00000000..e170d0ec --- /dev/null +++ b/docs/redesign-implementation-prompt.md @@ -0,0 +1,231 @@ +# 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. [x] 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. [x] 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. [x] 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. [x] 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. [x] 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. [x] 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. [x] 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. [x] 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. [x] 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. [x] 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. [x] 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. [x] 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. [x] In `ClockPage.tsx`, cap the layout to a centered `max-w-2xl` column at + desktop widths. Validate: plan-first gate still enforced. +14. [x] 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. + (Teams/Profile already use the default centered `max-w-4xl` column; + Tickets narrowed from `wide`→`content` (`max-w-4xl`); the Organization + page renders the full-bleed pannable org **chart** so it stays `flush` + per AppPage's canvas convention — its settings live in the + Overview/Members pages which already use centered columns.) +15. [x] 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. + _(Pending — requires a logged-in session; all automated checks pass.)_ +17. Confirm every acceptance criterion in the "Acceptance Criteria" section + below is checked off before calling this complete. + _(Automated criteria checked; the two manual smoke-test criteria remain + for a signed-in reviewer.)_ + +## 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 + ` - } - placement="bottom-end" + + {menuOpen && + createPortal( +
- {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, + )} ); @@ -380,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; } @@ -390,10 +451,14 @@ const FilterDropdown: React.FC = ({ placement = 'bottom-start', activeMenuId, menuId, + boundaryRef, onOpenChange, 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 +482,142 @@ 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; + shiftAppliedRef.current = false; + 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]); + + // `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. + 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, + )} + ); }; @@ -548,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); @@ -672,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); @@ -821,6 +1001,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], @@ -1017,7 +1198,7 @@ export const TicketsPage: React.FC = () => { 'ring-0 focus:ring-0 focus-visible:ring-0 focus:outline-none focus-visible:outline-none focus:border-blue-300 focus-visible:border-blue-300'; return ( - + {/* ── Header: New Ticket + Search ── */}
@@ -1159,7 +1340,11 @@ export const TicketsPage: React.FC = () => { )} {/* ── Unified ticket list (GitHub style) ── */} - + {/* GitHub-style header: Open / Closed tabs + filter dropdowns */}
{
- {/* Right: filter dropdowns */} -
+ {/* Right: filter dropdowns — horizontally scrollable chip row on mobile */} +
{teams.length > 1 && ( +
+ setOpenFilterMenu(open ? 'team' : null)} + > + setTeamFilter(null)} + className={!teamFilter ? 'font-semibold' : ''} + > + All teams + + + {teams.map((t: Team) => ( + setTeamFilter(t.id)} + className={teamFilter === t.id ? 'font-semibold' : ''} + > + {t.name} + + ))} + +
+ )} +
setOpenFilterMenu(open ? 'team' : null)} + onOpenChange={(open) => setOpenFilterMenu(open ? 'priority' : null)} > setTeamFilter(null)} - className={!teamFilter ? 'font-semibold' : ''} + onClick={() => setPriorityFilter(null)} + className={!priorityFilter ? 'font-semibold' : ''} > - All teams + Any priority - {teams.map((t: Team) => ( + {PRIORITY_OPTIONS.map((p) => ( setTeamFilter(t.id)} - className={teamFilter === t.id ? 'font-semibold' : ''} + key={p.value} + onClick={() => + setPriorityFilter(priorityFilter === p.value ? null : p.value) + } + className={priorityFilter === p.value ? 'font-semibold' : ''} > - {t.name} + {p.label} ))} - )} - setOpenFilterMenu(open ? 'priority' : null)} - > - setPriorityFilter(null)} - className={!priorityFilter ? 'font-semibold' : ''} +
+
+ setOpenFilterMenu(open ? 'status' : null)} > - Any priority - - - {PRIORITY_OPTIONS.map((p) => ( setPriorityFilter(priorityFilter === p.value ? null : p.value)} - className={priorityFilter === p.value ? 'font-semibold' : ''} + onClick={() => setStatusDetailFilter(null)} + className={!statusDetailFilter ? 'font-semibold' : ''} > - {p.label} + Any status - ))} - - setOpenFilterMenu(open ? 'status' : null)} - > - setStatusDetailFilter(null)} - className={!statusDetailFilter ? 'font-semibold' : ''} + + {STATUS_OPTIONS.filter( + (s) => s.value !== 'open' && s.value !== 'closed' && s.value !== 'reviewed', + ).map((s) => ( + + setStatusDetailFilter(statusDetailFilter === s.value ? null : s.value) + } + className={statusDetailFilter === s.value ? 'font-semibold' : ''} + > + {s.label} + + ))} + +
+
+ setOpenFilterMenu(open ? 'assignee' : null)} > - Any status - - - {STATUS_OPTIONS.filter( - (s) => s.value !== 'open' && s.value !== 'closed' && s.value !== 'reviewed', - ).map((s) => ( - setStatusDetailFilter(statusDetailFilter === s.value ? null : s.value) - } - className={statusDetailFilter === s.value ? 'font-semibold' : ''} + onClick={() => setAssigneeFilter(null)} + className={assigneeFilter === null ? 'font-semibold' : ''} > - {s.label} + Any - ))} - - setOpenFilterMenu(open ? 'assignee' : null)} - > - setAssigneeFilter(null)} - className={assigneeFilter === null ? 'font-semibold' : ''} - > - Any - - - - setAssigneeFilter( - assigneeFilter === '__unassigned__' ? null : '__unassigned__', - ) - } - className={assigneeFilter === '__unassigned__' ? 'font-semibold' : ''} - > - Unassigned - - {sortedMembers.length > 0 && } - {sortedMembers.map((m) => ( + setAssigneeFilter(assigneeFilter === m.id ? null : m.id)} - className={assigneeFilter === m.id ? 'font-semibold' : ''} + onClick={() => + setAssigneeFilter( + assigneeFilter === '__unassigned__' ? null : '__unassigned__', + ) + } + className={assigneeFilter === '__unassigned__' ? 'font-semibold' : ''} > - {m.id === userId ? `${m.name || m.email} (you)` : m.name || m.email} + Unassigned - ))} - + {sortedMembers.length > 0 && } + {sortedMembers.map((m) => ( + setAssigneeFilter(assigneeFilter === m.id ? null : m.id)} + className={assigneeFilter === m.id ? 'font-semibold' : ''} + > + {m.id === userId ? `${m.name || m.email} (you)` : m.name || m.email} + + ))} + +
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/TeamContext.tsx b/src/lib/TeamContext.tsx index 8cd6958c..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, @@ -209,6 +212,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 +232,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 +243,7 @@ export const TeamProvider: React.FC<{ children: React.ReactNode }> = ({ children }); return () => { + offDisconnect(); offChange(); unsubscribe(); }; @@ -443,6 +457,7 @@ export const TeamProvider: React.FC<{ children: React.ReactNode }> = ({ children const value = useMemo( () => ({ teams: scopedTeams, + allTeams: teams, pendingRequests, enterprises, organizations, @@ -465,6 +480,7 @@ export const TeamProvider: React.FC<{ children: React.ReactNode }> = ({ children }), [ scopedTeams, + teams, pendingRequests, enterprises, organizations, 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/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/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/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/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/pages/Huddle.tsx b/src/pages/Huddle.tsx index fd69af87..80c5c127 100644 --- a/src/pages/Huddle.tsx +++ b/src/pages/Huddle.tsx @@ -45,6 +45,43 @@ 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]); + + // 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() { @@ -190,10 +227,10 @@ export default function Huddle() { } return ( - -
+ +
{/* Feed / Drafts tabs + actions */} -
+
- {/* Current org/team scope */}
{/* ── 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. */} + + {!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. ── */ + + ))} + +
diff --git a/src/ui/AppLayout.tsx b/src/ui/AppLayout.tsx index 99106916..093fbd23 100644 --- a/src/ui/AppLayout.tsx +++ b/src/ui/AppLayout.tsx @@ -18,7 +18,6 @@ import { Capacitor } from '@capacitor/core'; import { PushNotifications } from '@capacitor/push-notifications'; import { ClockPage } from '../features/clock/ClockPage'; -import { TimesheetPage } from '../features/clock/TimesheetPage'; import { DashboardPage } from '../features/dashboard/DashboardPage'; import { MessagesPage } from '../features/messages/MessagesPage'; import { NotificationsPage } from '../features/notifications/NotificationsPage'; @@ -84,7 +83,6 @@ const ROUTES: Record = { : {}), '/app/teams': { title: 'Teams', component: TeamsPage }, '/app/tickets': { title: 'Tickets', component: TicketsPage }, - '/app/timesheet': { title: 'Timesheet', component: TimesheetPage }, '/app/work': { title: 'Work', component: WorkPage }, '/app/org/members': { title: 'Members', component: OrganizationMembersPage }, @@ -96,6 +94,17 @@ function match(pathname: string): RouteConfig | null { return ROUTES[pathname] ?? ROUTES['/app/dashboard']; } +/** + * Routes that no longer exist, and where their traffic goes now. Without this + * an old bookmark or push notification would fall through `match()` onto the + * dashboard's default view, silently losing what the link was pointing at. + * + * /app/timesheet → the personal timesheet, now Dashboard → Me → Timesheet + */ +const RETIRED_ROUTES: Record = { + '/app/timesheet': '/app/dashboard?view=timesheet', +}; + // ─── Context ───────────────────────────────────────────────────────────────── export interface SidebarCtx { @@ -143,23 +152,37 @@ const AppLayoutContent: React.FC = () => { useBrand(); - const normalizePath = (p: string) => (p === '/app' || p === '/' ? '/app/dashboard' : p); + // Maps a URL onto where it actually lives now: `/app` and `/` mean the + // dashboard, and a RETIRED_ROUTES path is rewritten to its replacement. + // Everything else passes through untouched, query string intact. + const resolveUrl = (url: string) => { + const path = url.split('?')[0]; + if (path === '/app' || path === '/') return '/app/dashboard'; + return RETIRED_ROUTES[path] ?? url; + }; const [pathname, setPathname] = useState(() => { if (typeof window === 'undefined') return '/app/dashboard'; - const p = window.location.pathname; - if (p === '/app' || p === '/') window.history.replaceState(null, '', '/app/dashboard'); - return normalizePath(p); + const current = window.location.pathname + window.location.search; + const resolved = resolveUrl(current); + if (resolved !== current) window.history.replaceState(null, '', resolved); + return resolved.split('?')[0]; }); const navigate = useCallback((path: string) => { - window.history.pushState(null, '', path); - setPathname(path.split('?')[0]); - window.dispatchEvent(new CustomEvent('timehuddle:navigate', { detail: { path } })); + const target = resolveUrl(path); + window.history.pushState(null, '', target); + setPathname(target.split('?')[0]); + window.dispatchEvent(new CustomEvent('timehuddle:navigate', { detail: { path: target } })); }, []); useEffect(() => { - const onPop = () => setPathname(normalizePath(window.location.pathname)); + const onPop = () => { + const current = window.location.pathname + window.location.search; + const resolved = resolveUrl(current); + if (resolved !== current) window.history.replaceState(null, '', resolved); + setPathname(resolved.split('?')[0]); + }; window.addEventListener('popstate', onPop); return () => window.removeEventListener('popstate', onPop); }, []); @@ -280,13 +303,19 @@ const AppLayoutContent: React.FC = () => { }, [navigate]); // ── Parameterized routes ────────────────────────────────────────────────── - // /app/profile/:id — numeric/ObjectId user ID - // /app/profile/:username — alphanumeric username (falls through from ID check) + // /app/profile/:id — Mongo ObjectId (24-char hex), numeric ID, or Meteor's + // default Accounts user _id (17-char Random.id() string — no idGeneration + // override is configured for Meteor.users, unlike the ObjectId-based + // collections, so plain userIds like those on huddle posts/tickets don't + // match the hex regex and would otherwise be misread as a username below). + // /app/profile/:username — anything else (falls through from the ID check) const profileSegment = pathname.startsWith('/app/profile/') ? pathname.slice('/app/profile/'.length) : null; const profileUserId = - profileSegment && /^[a-f0-9]{24}$|^\d+$/.test(profileSegment) ? profileSegment : null; + profileSegment && /^[a-f0-9]{24}$|^\d+$|^[A-Za-z0-9]{17}$/.test(profileSegment) + ? profileSegment + : null; const profileUsername = profileSegment && !profileUserId ? profileSegment : null; const ticketDetailId = @@ -389,7 +418,7 @@ const AppLayoutContent: React.FC = () => { setForegroundNotif(null); if (dismissTimer.current) clearTimeout(dismissTimer.current); }} - className="fixed top-4 left-1/2 -translate-x-1/2 z-9999 w-[90%] max-w-sm + className="fixed top-4 left-1/2 -translate-x-1/2 z-9999 w-[90%] max-w-sm md:w-auto md:max-w-md bg-neutral-900 dark:bg-neutral-800 text-white rounded-2xl shadow-xl px-4 py-3 cursor-pointer flex flex-col gap-0.5 border border-white/10" diff --git a/src/ui/AppPage.tsx b/src/ui/AppPage.tsx index b59ce9d8..2916b6fd 100644 --- a/src/ui/AppPage.tsx +++ b/src/ui/AppPage.tsx @@ -32,6 +32,9 @@ interface AppPageProps { width?: PageWidth; /** Optional supporting line rendered under the page title. */ subtitle?: string; + /** Optional controls rendered on the same row as the title, right-aligned + * (e.g. a Me/Team segmented toggle). Only rendered when there's a title. */ + titleActions?: React.ReactNode; /** Content fills the remaining height and manages its own scrolling. */ fill?: boolean; /** Content sits flush to the edges. Only for canvases (chat, org chart). */ @@ -44,6 +47,7 @@ interface AppPageProps { export const AppPage: React.FC = ({ width = 'content', subtitle, + titleActions, fill, flush, className, @@ -59,7 +63,10 @@ export const AppPage: React.FC = ({
{hasHeader && (
- +
+ + {titleActions &&
{titleActions}
} +
)} diff --git a/src/ui/BottomNav.tsx b/src/ui/BottomNav.tsx index 0730811e..f2a4a1cd 100644 --- a/src/ui/BottomNav.tsx +++ b/src/ui/BottomNav.tsx @@ -2,49 +2,218 @@ * BottomNav — Mobile-only bottom navigation bar. * * Visible only on small screens (md:hidden). - * Five tabs: Dashboard, Tickets, Clock In/Out (center FAB), Teams, Settings. + * Five tabs: Dashboard, Huddle, Clock In/Out (center FAB), Tickets, More. + * "More" opens a sheet with the remaining sidebar destinations (Teams, + * 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 { + faBug, + faBuilding, + faChevronLeft, faCircleStop, + faCircleUser, faClock, + faClockRotateLeft, + faComments, + faEllipsis, + faEnvelope, faGauge, faGear, + faCircleQuestion, faListCheck, + faPhotoFilm, + faSitemap, + faStopwatch, faUsers, + faWrench, + faXmark, } from '@fortawesome/free-solid-svg-icons'; +import { faApple } from '@fortawesome/free-brands-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 { 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; href: string; isFab?: boolean; + isMore?: boolean; } const TABS: NavTab[] = [ { icon: faGauge, label: 'Home', href: '/app/dashboard' }, - { icon: faListCheck, label: 'Tickets', href: '/app/tickets' }, + { 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: faStopwatch, label: 'Work', href: '/app/work' }, + { icon: faPhotoFilm, label: 'Media Library', href: '/app/media' }, + { icon: faEnvelope, label: 'Messages', href: '/app/messages' }, + { icon: faClockRotateLeft, label: 'Activity Log', href: '/app/activity' }, { 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'); + 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); + 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 () => { + document.body.style.overflow = original; + document.removeEventListener('keydown', onKeyDown); + }; + }, [moreOpen]); - // Plan-first gate: the FAB still navigates to the clock page (where the - // inline composer lives), but shows a dimmed "blocked" state so it's clear - // clocking in/out needs today's plan or wrap-up first. + const openMore = () => { + setMoreSection('root'); + setMoreOpen(true); + }; + const closeMore = () => { + setMoreOpen(false); + setMoreSection('root'); + moreButtonRef.current?.focus(); + }; + const goToMoreItem = (href: string) => { + closeMore(); + navigate(href); + }; + const goToProfile = () => { + 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. const planBlocked = planGate.planMissing || planGate.wrapUpMissing; return ( @@ -73,10 +242,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)' @@ -99,11 +265,13 @@ export const BottomNav: React.FC = () => { return ( + )} +

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

+ +
+
+ {moreSection === 'root' ? ( +
+ {MORE_ITEMS.map((item) => ( + + ))} + {showAdmin && ( + + )} + {showDevelopers && ( + + )} + +
+ ) : ( +
+ {SECTION_CONFIG[moreSection].rows.map((row) => ( + + ))} +
+ )} +
+ +
+ )} + ); }; diff --git a/src/ui/ClockInHeaderTimer.tsx b/src/ui/ClockInHeaderTimer.tsx index c7b3a632..8f717632 100644 --- a/src/ui/ClockInHeaderTimer.tsx +++ b/src/ui/ClockInHeaderTimer.tsx @@ -1,16 +1,18 @@ /** * ClockInHeaderTimer — Compact elapsed timer for the app header. * - * Displays only when the user has an active clock event. Uses the Timer - * component with an animated clock icon and live HH:MM:SS display. - * Tapping opens the clock in/out page. + * Displays only when the user has an active clock event. A compact gradient + * strand sits beside the live HH:MM:SS display, so a glance tells you the clock + * is actually counting; on break the count freezes and the strand flattens. + * Above md the header runs a full-width strand of its own, so this one steps + * aside there. Tapping opens the clock in/out page. */ import React, { useCallback } from 'react'; import { useTeam } from '../lib/TeamContext'; import { formatTimer, getActiveClockSeconds } from '../lib/timeUtils'; import { useRouter } from './router'; -import { TimerRoot, TimerIcon, TimerDisplay } from './Timer'; +import { TimerRoot, TimerDisplay } from './Timer'; export const ClockInHeaderTimer: React.FC = () => { const { navigate } = useRouter(); @@ -24,16 +26,25 @@ export const ClockInHeaderTimer: React.FC = () => { const elapsedSeconds = getActiveClockSeconds(activeClockEvent, currentTime); const display = formatTimer(elapsedSeconds); + // getActiveClockSeconds freezes the count while paused, so the pill must not + // keep animating as though it were running — the strand goes flat, which is + // what marks "on break" here. The pill itself stays neutral in both states: + // the green "success" fill fought the gradient beside it. + const isPaused = !!activeClockEvent.isPaused; return ( { if (e.key === 'Enter' || e.key === ' ') { @@ -42,12 +53,7 @@ export const ClockInHeaderTimer: React.FC = () => { } }} > - - {/* Below sm the header also carries the page title and the org/team - switcher; the elapsed digits yield so those stay legible. The icon - still signals "clocked in", the aria-label above still announces the - time, and the tab title carries it too (useClockDocumentTitle). */} - + ); }; 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/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/OrgTeamSwitcher.tsx b/src/ui/OrgTeamSwitcher.tsx index 39b46743..23c6fd74 100644 --- a/src/ui/OrgTeamSwitcher.tsx +++ b/src/ui/OrgTeamSwitcher.tsx @@ -2,17 +2,24 @@ * OrgTeamSwitcher — Header control showing and switching the current scope. * * Renders `Org ▸ Team` so the active scope is legible without opening - * anything, and opens a single panel holding both lists. + * anything, and opens a single panel holding both lists — a full-width + * bottom sheet on mobile, a centered dialog on desktop (shared `Modal` + * primitive), matching the redesign prototype's "Switch organization / team" + * sheet: an Organization select, a Team list with member counts, and a + * "+ New team" action. * * Switching is client-side only: TeamContext persists the selection and * re-scopes `teams` to the selected org, auto-picking a valid team. */ -import { faCheck, faChevronDown, faChevronRight, faClock } from '@fortawesome/free-solid-svg-icons'; +import { faChevronDown, faClock } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Badge, Dropdown, DropdownItem, DropdownLabel, DropdownSeparator, 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'; import { useTeam } from '../lib/TeamContext'; +import { Logo } from './Logo'; +import { useRouter } from './router'; type OrganizationRole = 'owner' | 'admin' | 'member'; @@ -22,23 +29,6 @@ const ROLE_LABEL: Record = { member: 'Member', }; -/** - * DropdownItem wraps its children in its own `` that defaults to - * `min-width: auto`, so a long name would push the row past the menu edge - * instead of ellipsing. Let that wrapper shrink. - */ -const ROW = 'min-w-0 [&>span]:min-w-0'; - -/** Check icon that reserves its space so rows don't shift when selection moves. */ -const SelectedCheck: React.FC<{ selected: boolean }> = ({ selected }) => ( - -); - export const OrgTeamSwitcher: React.FC = () => { const { organizations, @@ -50,6 +40,7 @@ export const OrgTeamSwitcher: React.FC = () => { teamsReady, pendingRequests, } = useTeam(); + const { navigate } = useRouter(); const [open, setOpen] = useState(false); @@ -74,13 +65,6 @@ export const OrgTeamSwitcher: React.FC = () => { return counts; }, [pendingRequests]); - // Selecting an org leaves the panel open so the re-scoped team list below - // can be picked from; selecting a team completes the task and closes it. - const handleSelectOrg = useCallback( - (organizationId: string) => setSelectedOrgId(organizationId), - [setSelectedOrgId], - ); - const handleSelectTeam = useCallback( (teamId: string) => { setSelectedTeamId(teamId); @@ -89,6 +73,11 @@ export const OrgTeamSwitcher: React.FC = () => { [setSelectedTeamId], ); + const handleNewTeam = useCallback(() => { + setOpen(false); + navigate('/app/teams'); + }, [navigate]); + if (organizations.length === 0 && teams.length === 0) return null; const teamLabel = selectedTeam?.name ?? (teamsReady ? 'No team' : '…'); @@ -100,137 +89,149 @@ export const OrgTeamSwitcher: React.FC = () => { return ( /* Shares the header squeeze with the page title rather than forcing the - title to absorb all of it — both ellipse instead of one vanishing. - Dropdown renders its own `relative inline-flex` container that defaults - to `min-width: auto`; without relaxing it the trigger refuses to shrink - and spills over the header's right-hand controls. */ -
- - {selectedOrg && ( - - + + + {createPortal( + + Switch organization / team + + {organizations.length > 0 && ( +
+ - {selectedOrg.name} - - +