import type { Meta, StoryObj } from '@storybook/nextjs-vite' import * as React 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] = React.useState(initial.status ?? 'ready') const [runningSince, setRunningSince] = React.useState(initial.runningSince ?? null) const [accumulatedMs, setAccumulatedMs] = React.useState(initial.accumulatedMs ?? 0) const [selectedTicketId, setSelectedTicketId] = React.useState(initial.selectedTicketId ?? null) const [notes, setNotes] = React.useState(initial.notes ?? '') const [lastEntries, setLastEntries] = React.useState(initial.lastEntries ?? SEED_ENTRIES) const [ticketOptions, setTicketOptions] = React.useState(ALL_TICKETS) const [ticketsLoading, setTicketsLoading] = React.useState(false) const searchRef = React.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.
) }, }