import type { Meta, StoryObj } from '@storybook/nextjs-vite'; import { useState, useRef } from 'react'; import { TimeTrackerHeaderButton, TimeTrackerPanel, TimeTrackerProvider, type TimeTrackerData, type TimeTrackerEntry, type TimeTrackerStatus, } from '../components/features/time-tracker'; // --------------------------------------------------------------------------- // Mock data + a stand-in "host" that simulates the openframe-frontend backend // (timer transitions, ticket search, persisting finished entries). // --------------------------------------------------------------------------- const ALL_TICKETS = [ { id: 'TICK-101', label: '#101 · Printer not working' }, { id: 'TICK-102', label: '#102 · VPN connection failed' }, { id: 'TICK-103', label: '#103 · Outlook keeps crashing' }, { id: 'TICK-104', label: '#104 · Disk Cleanup Assistance' }, { id: 'TICK-105', label: '#105 · Wi-Fi dropping intermittently' }, { id: 'TICK-106', label: '#106 · New employee laptop setup' }, { id: 'TICK-107', label: '#107 · Two-factor authentication locked out' }, ]; const SEED_ENTRIES: TimeTrackerEntry[] = [ { id: 'e1', durationLabel: '00:08:17', dateLabel: '07/12/25', title: 'Disk Cleanup Assistance', description: 'Walked user through Storage Sense setup, scheduled monthly cleanup', }, { id: 'e2', durationLabel: '00:23:04', dateLabel: '07/11/25', title: 'VPN connection failed', description: 'Reissued client certificate and verified split-tunnel routes', }, { id: 'e3', durationLabel: '01:12:49', dateLabel: '07/10/25', title: 'New employee laptop setup', description: 'Imaged device, joined domain, installed standard software bundle', }, ]; function pad(n: number) { return String(n).padStart(2, '0'); } function formatDuration(ms: number) { const total = Math.max(0, Math.floor(ms / 1000)); return `${pad(Math.floor(total / 3600))}:${pad(Math.floor((total % 3600) / 60))}:${pad(total % 60)}`; } function formatDate(d: Date) { return `${pad(d.getMonth() + 1)}/${pad(d.getDate())}/${String(d.getFullYear()).slice(-2)}`; } const meta = { title: 'Features/TimeTracker', component: TimeTrackerPanel, parameters: { layout: 'padded', docs: { description: { component: 'Purely-UI time tracker. The host wraps the app in `` (supplying `status`, `runningSince`, ticket options, last entries, and the start/pause/resume/submit callbacks) and passes `showTimeTracker` to `AppHeader`. The header button shows the live elapsed time while a session is active and opens the popup below it. The timer is display-only — it ticks from the host-provided `runningSince`; the backend owns the real start time.', }, }, }, // Default args satisfy the panel's required props for autodocs; every story // below overrides them with an interactive `render`. args: { status: 'ready', ticketOptions: ALL_TICKETS, selectedTicketId: null, onSelectedTicketChange: () => {}, notes: '', onNotesChange: () => {}, lastEntries: SEED_ENTRIES, onStart: () => {}, onPause: () => {}, onResume: () => {}, onCancel: () => {}, onSubmit: () => {}, onClose: () => {}, }, } satisfies Meta; export default meta; type Story = StoryObj; interface HostInit { status?: TimeTrackerStatus; runningSince?: number | null; accumulatedMs?: number; selectedTicketId?: string | null; notes?: string; lastEntries?: TimeTrackerEntry[]; /** When true, ticket options are filtered asynchronously (server-search simulation). */ serverSearch?: boolean; } function useTimeTrackerHost(initial: HostInit = {}): TimeTrackerData { const [status, setStatus] = useState(initial.status ?? 'ready'); const [runningSince, setRunningSince] = useState(initial.runningSince ?? null); const [accumulatedMs, setAccumulatedMs] = useState(initial.accumulatedMs ?? 0); const [selectedTicketId, setSelectedTicketId] = useState(initial.selectedTicketId ?? null); const [notes, setNotes] = useState(initial.notes ?? ''); const [lastEntries, setLastEntries] = useState(initial.lastEntries ?? SEED_ENTRIES); const [ticketOptions, setTicketOptions] = useState(ALL_TICKETS); const [ticketsLoading, setTicketsLoading] = useState(false); const searchRef = useRef | undefined>(undefined); const currentElapsedMs = () => accumulatedMs + (status === 'tracking' && runningSince ? Date.now() - runningSince : 0); const onTicketSearch = initial.serverSearch ? (query: string) => { setTicketsLoading(true); clearTimeout(searchRef.current); searchRef.current = setTimeout(() => { const q = query.toLowerCase(); setTicketOptions(ALL_TICKETS.filter(t => t.label.toLowerCase().includes(q))); setTicketsLoading(false); }, 350); } : undefined; return { status, runningSince, accumulatedMs, ticketOptions, selectedTicketId, onSelectedTicketChange: setSelectedTicketId, onTicketSearch, ticketsLoading: initial.serverSearch ? ticketsLoading : undefined, notes, onNotesChange: setNotes, lastEntries, onStart: () => { setAccumulatedMs(0); setRunningSince(Date.now()); setStatus('tracking'); }, onPause: () => { setAccumulatedMs(currentElapsedMs()); setRunningSince(null); setStatus('paused'); }, onResume: () => { setRunningSince(Date.now()); setStatus('tracking'); }, onCancel: () => { setStatus('ready'); setRunningSince(null); setAccumulatedMs(0); setSelectedTicketId(null); setNotes(''); }, onSubmit: () => { const ms = currentElapsedMs(); const ticketId = selectedTicketId; const entry: TimeTrackerEntry = { id: `entry-${Date.now()}`, durationLabel: formatDuration(ms), dateLabel: formatDate(new Date()), title: ticketId ? (ALL_TICKETS.find(t => t.id === ticketId)?.label ?? ticketId) : 'Manual time entry', description: notes || undefined, }; setLastEntries(prev => [entry, ...prev].slice(0, 3)); setStatus('ready'); setRunningSince(null); setAccumulatedMs(0); setSelectedTicketId(null); setNotes(''); }, onManualEntry: () => alert('Host: open the manual-entry form modal'), onOpenMyTime: () => alert('Host: navigate to "My Time"'), onOpenMyTimeMenu: () => alert('Host: open the "My Time" split menu'), onEntryClick: entry => alert(`Host: navigate to entry "${entry.title}"`), }; } /** Mock header strip so the `HeaderButton` resolves its `h-full` and the popover anchors correctly. */ function HeaderBar() { return (
); } // --------------------------------------------------------------------------- // Panel stories — the popup content in each status. Fully interactive: the // play/pause/stop buttons drive the same transitions the host would. // --------------------------------------------------------------------------- /** Idle. Stop button is hidden until tracking starts; pre-seeded last entries. */ export const PanelReady: Story = { render: function PanelReadyRender() { const host = useTimeTrackerHost({ status: 'ready' }); return (
{}} />
); }, }; /** Tracking — the timer ticks live and a ticket is selected, so Stop (finish) is enabled. */ export const PanelTracking: Story = { render: function PanelTrackingRender() { const host = useTimeTrackerHost({ status: 'tracking', runningSince: Date.now() - 5_000, selectedTicketId: 'TICK-102', }); return (
{}} />
); }, }; /** Paused at 00:08:17 with notes filled — Stop (finish) is enabled via the notes rule. */ export const PanelPaused: Story = { render: function PanelPausedRender() { const host = useTimeTrackerHost({ status: 'paused', accumulatedMs: 497_000, notes: 'Walked user through Storage Sense setup, scheduled monthly cleanup.', }); return (
{}} />
); }, }; /** * Tracking with no ticket and no notes — pressing Finish (the yellow check) surfaces inline * validation errors on both fields instead of submitting. Add a ticket or a note and Finish succeeds. */ export const PanelFinishGate: Story = { render: function PanelFinishGateRender() { const host = useTimeTrackerHost({ status: 'tracking', runningSince: Date.now() - 42_000 }); return (
{}} />
); }, }; /** Empty last-entries state — clock-history icon with a "No time logged / Last entries will appear here" placeholder (no list header or border). */ export const PanelNoEntries: Story = { render: function PanelNoEntriesRender() { const host = useTimeTrackerHost({ status: 'ready', lastEntries: [] }); return (
{}} />
); }, }; // --------------------------------------------------------------------------- // Header button stories — the trigger as it appears in the app header. // --------------------------------------------------------------------------- /** Idle header button — clock icon only. Click it to open the popup. */ export const HeaderButtonIdle: Story = { parameters: { layout: 'fullscreen' }, render: function HeaderButtonIdleRender() { const host = useTimeTrackerHost({ status: 'ready' }); return (

Click the clock icon to open the time tracker.

); }, }; /** Active header button — shows the live elapsed time next to the clock icon. */ export const HeaderButtonTracking: Story = { parameters: { layout: 'fullscreen' }, render: function HeaderButtonTrackingRender() { const host = useTimeTrackerHost({ status: 'tracking', runningSince: Date.now() - 65_000 }); return (

While tracking, the header shows the running timer. Click it to open the popup.

); }, }; /** Paused header button — clock icon and timer are greyed out (frozen elapsed time). */ export const HeaderButtonPaused: Story = { parameters: { layout: 'fullscreen' }, render: function HeaderButtonPausedRender() { const host = useTimeTrackerHost({ status: 'paused', accumulatedMs: 65_000 }); return (

While paused, the clock and timer are muted to signal the session is on hold.

); }, }; // --------------------------------------------------------------------------- // Full interactive playground — provider + header button + simulated server // ticket search + real start/pause/resume/finish that persists into Last Entries. // --------------------------------------------------------------------------- export const Playground: Story = { parameters: { layout: 'fullscreen' }, render: function PlaygroundRender() { const host = useTimeTrackerHost({ serverSearch: true }); return (

Time tracker playground

  • Open the popup from the clock button in the header above.
  • Press play to start; the header and panel timers tick from the start time.
  • Typing in “Assign Ticket” runs a simulated async search (loading spinner).
  • Stop is disabled until you pick a ticket or type a note.
  • Finishing pushes a new row into Last Entries (capped at 3) and resets to idle.
); }, };