diff --git a/.changeset/bumpy-donkeys-watch.md b/.changeset/bumpy-donkeys-watch.md new file mode 100644 index 0000000..929fdb7 --- /dev/null +++ b/.changeset/bumpy-donkeys-watch.md @@ -0,0 +1,5 @@ +--- +"wtc": patch +--- + +Design tweaks and cleanup. diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 053b3d6..b8a97c9 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -73,7 +73,7 @@ function Home() { name: "github.open", title: "Open GitHub", desc: "Repository workflows", - category: "Navigation", + category: "GitHub", run: () => { navigate({ page: "github" }); dialog.clear(); @@ -83,7 +83,7 @@ function Home() { name: "teamwork.open", title: "Open Teamwork", desc: "My assigned work", - category: "Navigation", + category: "Teamwork", run: () => { navigate({ page: "teamwork", @@ -96,7 +96,7 @@ function Home() { name: "teamwork.project.open", title: "Open Teamwork Project", desc: "Project-specific Teamwork context", - category: "Navigation", + category: "Teamwork", run: () => { navigate({ page: "teamwork", tab: "project" }); dialog.clear(); @@ -106,7 +106,7 @@ function Home() { name: "teamwork.timers.open", title: "Open Teamwork Timers", desc: "Local timer tracking", - category: "Navigation", + category: "Teamwork", run: () => { navigate({ page: "teamwork", tab: "timers" }); dialog.clear(); @@ -116,7 +116,7 @@ function Home() { name: "teamwork.timesheet.open", title: "Open Teamwork Timesheet", desc: "Open Teamwork time tracking in browser", - category: "Navigation", + category: "Teamwork", run: () => { dialog.clear(); void openUrlInBrowser(TEAMWORK_TIMESHEET_URL); @@ -126,7 +126,7 @@ function Home() { name: "settings.open", title: "Open Settings", desc: "Configuration and setup", - category: "Navigation", + category: "Settings", run: () => { navigate({ page: "settings" }); dialog.clear(); diff --git a/src/tui/components/dialog-select.tsx b/src/tui/components/dialog-select.tsx index dbcc41d..d3a2098 100644 --- a/src/tui/components/dialog-select.tsx +++ b/src/tui/components/dialog-select.tsx @@ -1,4 +1,4 @@ -import { createMemo, createSignal } from "solid-js"; +import { createMemo, createSignal, For, Show } from "solid-js"; import { InputRenderable, TextAttributes } from "@opentui/core"; import { useBindings } from "@opentui/keymap/solid"; import { tokens } from "../tokens.ts"; @@ -17,7 +17,7 @@ export interface DialogSelectOption { value: T; /** Optional secondary text shown beside the title. */ description?: string; - /** Optional grouping metadata, currently used by filtering. */ + /** Optional grouping metadata rendered as a section heading. */ category?: string; /** Reserved for future contextual footer text. */ footer?: string; @@ -25,6 +25,12 @@ export interface DialogSelectOption { onSelect?: () => void; } +interface DialogOptionSection { + category: string; + options: DialogSelectOption[]; + startIndex: number; +} + /** * Filters dialog select options using the title, description, and category. * @@ -58,6 +64,28 @@ export function DialogSelect(props: { title: string; options: DialogSelectOpt const [query, setQuery] = createSignal(""); const [selectedIndex, setSelectedIndex] = createSignal(0); const filtered = createMemo(() => filterDialogSelectOptions(props.options, query())); + + const sections = createMemo(() => { + const items = filtered(); + const groups = new Map[]>(); + for (const item of items) { + const cat = item.category ?? "Other"; + const group = groups.get(cat); + if (group) { + group.push(item); + } else { + groups.set(cat, [item]); + } + } + let startIndex = 0; + const result: DialogOptionSection[] = []; + for (const [category, groupOptions] of groups) { + result.push({ category, options: groupOptions, startIndex }); + startIndex += groupOptions.length; + } + return result; + }); + let input: InputRenderable | undefined; const move = (direction: 1 | -1) => { @@ -108,7 +136,7 @@ export function DialogSelect(props: { title: string; options: DialogSelectOpt })); return ( - + {props.title} @@ -134,18 +162,41 @@ export function DialogSelect(props: { title: string; options: DialogSelectOpt }, 1); }} /> - - {filtered().map((option, index) => ( - option.onSelect?.()} - > - - {option.title} - - {option.description && — {option.description}} - - ))} + + + {(section, sectionIndex) => ( + + 0}> + + + + {section.category} + + + {(option, localIndex) => { + const globalIndex = section.startIndex + localIndex(); + return ( + option.onSelect?.()} + > + + {option.title} + + {option.description && ( + — {option.description} + )} + + ); + }} + + + )} + {filtered().length === 0 && No matching commands} ↑↓ navigate · enter select · esc close diff --git a/src/tui/components/layout/accordion-section.tsx b/src/tui/components/layout/accordion-section.tsx index 8c2d017..2b97ef9 100644 --- a/src/tui/components/layout/accordion-section.tsx +++ b/src/tui/components/layout/accordion-section.tsx @@ -26,12 +26,12 @@ function descriptions(value: AccordionSectionProps["description"]): readonly str export function AccordionSection(props: AccordionSectionProps) { return ( diff --git a/src/tui/components/layout/card.tsx b/src/tui/components/layout/card.tsx new file mode 100644 index 0000000..00a7861 --- /dev/null +++ b/src/tui/components/layout/card.tsx @@ -0,0 +1,41 @@ +import { Show, type ParentProps } from "solid-js"; + +import { tokens } from "../../tokens.ts"; + +/** Props for a titled content card used to group related UI sections. */ +export interface CardProps extends ParentProps { + title?: string; + status?: string; + active?: boolean; +} + +/** + * Full-rounded-border grouping container for content sections. + * + * Use `Card` for major visual groups (pinned task lists, settings sections, + * timer lists) and nest `ListItem` entries inside it. Avoid nesting Cards + * more than one level deep. + */ +export function Card(props: CardProps) { + return ( + + + + {props.title} + + {props.status} + + + + + {props.children} + + ); +} diff --git a/src/tui/components/layout/list-item.tsx b/src/tui/components/layout/list-item.tsx new file mode 100644 index 0000000..d9df844 --- /dev/null +++ b/src/tui/components/layout/list-item.tsx @@ -0,0 +1,51 @@ +import { Show, type JSX } from "solid-js"; + +import { tokens } from "../../tokens.ts"; + +/** Props for a compact selectable list item inside a Card. */ +export interface ListItemProps { + id?: string; + title: string; + metadata?: readonly string[]; + selected?: boolean; + badge?: JSX.Element; +} + +/** Separator used between inline metadata segments. */ +const METADATA_SEPARATOR = " • "; + +/** + * Compact selectable entry for use inside a `Card`. + * + * Renders a thin left accent bar, title, optional inline metadata, and an + * optional right-aligned badge. Use for task entries, timer entries, and + * any other list-style content. + */ +export function ListItem(props: ListItemProps) { + const metadataLine = () => { + const parts = props.metadata; + if (!parts || parts.length === 0) return null; + return parts.join(METADATA_SEPARATOR); + }; + + return ( + + + + + + {props.selected ? "● " : ""} + {props.title} + + + + {metadataLine()} + + + + + {props.badge} + + + ); +} diff --git a/src/tui/components/layout/section.tsx b/src/tui/components/layout/section.tsx deleted file mode 100644 index 5e59800..0000000 --- a/src/tui/components/layout/section.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { children, type ParentProps, Show } from "solid-js"; -import { TextAttributes } from "@opentui/core"; - -import { tokens } from "../../tokens.ts"; - -/** Props for a titled content section inside a TUI page. */ -export interface SectionProps extends ParentProps { - /** Section title. */ - title: string; - /** Optional secondary lines shown beneath the title. */ - description?: string | readonly string[]; - active?: boolean; -} - -function descriptions(value: SectionProps["description"]): readonly string[] { - if (!value) return []; - return typeof value === "string" ? [value] : value.filter(Boolean); -} - -/** - * Standard section wrapper for grouped page content. - * - * Use this for form sections, settings groups, and future configuration panels - * so headings and detail text remain consistent across pages. - */ -export function Section(props: SectionProps) { - const resolved = children(() => props.children); - - return ( - - - - - {props.title} - - {descriptions(props.description).map((description) => ( - {description} - ))} - - - - {resolved()} - - - - - ); -} diff --git a/src/tui/components/teamwork/task-list.tsx b/src/tui/components/teamwork/task-list.tsx index 8822428..0099186 100644 --- a/src/tui/components/teamwork/task-list.tsx +++ b/src/tui/components/teamwork/task-list.tsx @@ -1,46 +1,52 @@ -import { For, Show } from "solid-js"; +import { For } from "solid-js"; import type { TeamworkTask } from "../../../teamwork/task-list-tasks.ts"; +import { type LocalTimerEntry, getLocalTimerElapsedMs } from "../../../teamwork/timers/local.ts"; import { tokens } from "../../tokens.ts"; import { buildTaskMetadata } from "./task-metadata.tsx"; -import { TimerIndicator } from "./timer-indicator.tsx"; -import { Section } from "../layout/section.tsx"; +import { TimerBadge, type TimerBadgeProps } from "./timer-indicator.tsx"; +import { ListItem } from "../layout/list-item.tsx"; -/** Renders a list of tasks with name, status, and styled metadata row. Supports keyboard selection highlight. */ +/** Renders a list of tasks with compact list items, selection, and inline timer badges. */ export function TaskList(props: { taskListId: number; tasks: readonly TeamworkTask[]; emptyMessage: string; selectedTaskId?: number | null; - timerTaskIds?: readonly number[]; - runningTaskId?: number | null; + localTimers?: readonly LocalTimerEntry[]; + now?: Date; flashOn?: boolean; }) { + const timerBadge = (taskId: number): TimerBadgeProps | null => { + const timers = props.localTimers; + if (!timers) return null; + + const timer = timers.find((t) => t.taskId === taskId); + if (!timer) return null; + + return { + elapsedMs: getLocalTimerElapsedMs(timer, props.now ?? new Date()), + running: timer.status === "running", + flashOn: props.flashOn, + }; + }; + return props.tasks.length ? ( - + {(task) => { - const timerStatus = () => - props.timerTaskIds?.includes(task.id) - ? props.runningTaskId === task.id - ? "running" - : "stopped" - : null; + const badge = timerBadge(task.id); return ( - -
- - - -
-
+ : undefined} + /> ); }}
diff --git a/src/tui/components/teamwork/timer-indicator.tsx b/src/tui/components/teamwork/timer-indicator.tsx index d8558ee..fe87cc8 100644 --- a/src/tui/components/teamwork/timer-indicator.tsx +++ b/src/tui/components/teamwork/timer-indicator.tsx @@ -1,17 +1,37 @@ import { tokens } from "../../tokens.ts"; -/** Visual state for a local timer indicator. */ -export type TimerIndicatorStatus = "running" | "stopped"; +/** Props for a compact timer duration badge shown inline in list items. */ +export interface TimerBadgeProps { + elapsedMs: number; + running: boolean; + flashOn?: boolean; +} + +/** + * Compact inline timer badge showing elapsed time. + * + * Running timers use an accent color with an optional flash; stopped timers + * are shown in dim text. Format is "⏱ 1h 23m" or "⏱ 0m 45s". + */ +export function TimerBadge(props: TimerBadgeProps) { + const formatted = formatBadgeDuration(props.elapsedMs); -/** Shared local timer indicator used anywhere task timers are shown. */ -export function TimerIndicator(props: { status: TimerIndicatorStatus; flashOn?: boolean }) { return ( - - ⏱ {props.status === "running" ? "Running" : "Stopped"} + + ⏱ {formatted} ); } + +function formatBadgeDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes.toString().padStart(2, "0")}m`; + } + + return `${minutes}m ${seconds.toString().padStart(2, "0")}s`; +} diff --git a/src/tui/pages/teamwork/my-work-tab.tsx b/src/tui/pages/teamwork/my-work-tab.tsx index 272ae72..8ac2bde 100644 --- a/src/tui/pages/teamwork/my-work-tab.tsx +++ b/src/tui/pages/teamwork/my-work-tab.tsx @@ -1,14 +1,11 @@ -import { Section } from "../../components/layout/section.tsx"; import { tokens } from "../../tokens.ts"; +import { Card } from "../../components/layout/card.tsx"; /** Placeholder tab for global Teamwork tasks assigned to the current user. */ export function MyWorkTab() { return ( -
- Assigned tasks and timers will appear here later. -
+ + Assigned tasks and timers will appear here later. + ); } diff --git a/src/tui/pages/teamwork/project-tab.tsx b/src/tui/pages/teamwork/project-tab.tsx index f8f947e..b905584 100644 --- a/src/tui/pages/teamwork/project-tab.tsx +++ b/src/tui/pages/teamwork/project-tab.tsx @@ -1,5 +1,4 @@ import { createEffect, createSignal, For, onCleanup, onMount } from "solid-js"; -import { TextAttributes } from "@opentui/core"; import { useBindings } from "@opentui/keymap/solid"; import { loadResolvedConfig } from "../../../config/manager.ts"; @@ -18,6 +17,7 @@ import { } from "../../../teamwork/timers/local.ts"; import { getTeamworkTaskReference } from "../../../teamwork/tasks.ts"; import { openUrlInBrowser } from "../../../utils/browser.ts"; +import { Card } from "../../components/layout/card.tsx"; import { ConfirmDialog } from "../../components/confirm-dialog.tsx"; import { TaskList } from "../../components/teamwork/task-list.tsx"; import { useDialog } from "../../components/dialog.tsx"; @@ -250,58 +250,33 @@ export function ProjectTab() { }); return ( - - + + {projectMetadata() ? ( - - {projectMetadata()?.project.name} - {projectMessage()} - + {projectMessage()} ) : ( {projectMessage()} )} - - - {(resolved()?.project?.project.links.length ?? 0) > 0 && ( - - - Project links - - - {(link) => ( - - {link.name}: {link.url} - - )} - - - )} + + {(resolved()?.project?.project.links.length ?? 0) > 0 && ( + + Project links + + {(link) => ( + + {link.name}: {link.url} + + )} + + + )} + {(resolved()?.project?.teamwork.pinnedTaskLists.length ?? 0) > 0 && ( - + {(taskList) => ( - + {taskList.message ? ( {taskList.message} ) : ( @@ -312,17 +287,14 @@ export function ProjectTab() { selectedTaskId={ selectedTask()?.taskListId === taskList.id ? selectedTask()?.taskId : null } - timerTaskIds={localTimers().map((t) => t.taskId)} - runningTaskId={ - localTimers().find((t) => t.status === "running")?.taskId ?? null - } + localTimers={localTimers()} flashOn={flashOn()} /> )} - + )} - + )} ); diff --git a/src/tui/pages/teamwork/timers-tab.tsx b/src/tui/pages/teamwork/timers-tab.tsx index 0ebb7e2..3277909 100644 --- a/src/tui/pages/teamwork/timers-tab.tsx +++ b/src/tui/pages/teamwork/timers-tab.tsx @@ -2,7 +2,6 @@ import { createEffect, createSignal, For, onCleanup, onMount } from "solid-js"; import { useBindings } from "@opentui/keymap/solid"; import { - formatTimerDuration, getLocalTimerElapsedMs, loadLocalTimers, removeLocalTimer, @@ -12,10 +11,11 @@ import { import { createTaskTimeEntry } from "../../../teamwork/timers.ts"; import { TEAMWORK_TIMESHEET_URL } from "../../../teamwork/consts.ts"; import { openUrlInBrowser } from "../../../utils/browser.ts"; +import { Card } from "../../components/layout/card.tsx"; +import { ListItem } from "../../components/layout/list-item.tsx"; import { ConfirmDialog } from "../../components/confirm-dialog.tsx"; -import { TimerIndicator } from "../../components/teamwork/timer-indicator.tsx"; +import { TimerBadge } from "../../components/teamwork/timer-indicator.tsx"; import { useDialog } from "../../components/dialog.tsx"; -import { Section } from "../../components/layout/section.tsx"; import { tokens } from "../../tokens.ts"; /** Cycles through local timer IDs in display order, wrapping around. */ @@ -106,14 +106,15 @@ export function TimersTab() { return; } - const totalMinutes = Math.max(1, Math.ceil(getLocalTimerElapsedMs(timer, now()) / 60_000)); - const duration = formatTimerDuration(totalMinutes * 60_000); + const elapsedMs = getLocalTimerElapsedMs(timer, now()); + const totalMinutes = Math.max(1, Math.ceil(elapsedMs / 60_000)); + const durationMinutes = `${totalMinutes}m`; const action = timer.status === "running" ? "Stop and submit" : "Submit"; dialog.replace(() => ( { try { @@ -229,35 +230,48 @@ export function TimersTab() { }); }); + const timerMetadata = (timer: LocalTimerEntry): string[] => { + const parts: string[] = []; + const elapsedMs = getLocalTimerElapsedMs(timer, now()); + parts.push(formatInlineDuration(elapsedMs)); + return parts; + }; + return ( - - {localTimers().length > 0 ? "Local Timers" : "No timers"} - {message()} + + 0 ? "Local Timers" : "No timers"}> + {message()} - - {(timer) => ( -
- -
- )} -
+ + {(timer) => ( + + } + /> + )} + +
); } + +function formatInlineDuration(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + if (hours > 0) { + return `${hours}h ${minutes.toString().padStart(2, "0")}m ${seconds.toString().padStart(2, "0")}s`; + } + + return `${minutes}m ${seconds.toString().padStart(2, "0")}s`; +}