mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
improved timer system and overlay
This commit is contained in:
@@ -97,6 +97,35 @@ export async function getIssueByID(id: number) {
|
|||||||
return issue;
|
return issue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getIssueWithUsersById(issueId: number): Promise<IssueResponse | null> {
|
||||||
|
const Creator = aliasedTable(User, "Creator");
|
||||||
|
|
||||||
|
const [issueWithCreator] = await db
|
||||||
|
.select({
|
||||||
|
Issue: Issue,
|
||||||
|
Creator: Creator,
|
||||||
|
})
|
||||||
|
.from(Issue)
|
||||||
|
.where(eq(Issue.id, issueId))
|
||||||
|
.innerJoin(Creator, eq(Issue.creatorId, Creator.id));
|
||||||
|
|
||||||
|
if (!issueWithCreator) return null;
|
||||||
|
|
||||||
|
const assigneesData = await db
|
||||||
|
.select({
|
||||||
|
User: User,
|
||||||
|
})
|
||||||
|
.from(IssueAssignee)
|
||||||
|
.innerJoin(User, eq(IssueAssignee.userId, User.id))
|
||||||
|
.where(eq(IssueAssignee.issueId, issueId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
Issue: issueWithCreator.Issue,
|
||||||
|
Creator: issueWithCreator.Creator,
|
||||||
|
Assignees: assigneesData.map((row) => row.User),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function getIssueByNumber(projectId: number, number: number) {
|
export async function getIssueByNumber(projectId: number, number: number) {
|
||||||
const [issue] = await db
|
const [issue] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { TimedSession } from "@sprint/shared";
|
import { Issue, Project, TimedSession } from "@sprint/shared";
|
||||||
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
|
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
|
||||||
import { db } from "../client";
|
import { db } from "../client";
|
||||||
|
|
||||||
@@ -80,6 +80,26 @@ export async function getUserTimedSessions(userId: number, limit = 50, offset =
|
|||||||
return timedSessions;
|
return timedSessions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getActiveTimedSessionsWithIssue(userId: number) {
|
||||||
|
const timedSessions = await db
|
||||||
|
.select({
|
||||||
|
id: TimedSession.id,
|
||||||
|
userId: TimedSession.userId,
|
||||||
|
issueId: TimedSession.issueId,
|
||||||
|
timestamps: TimedSession.timestamps,
|
||||||
|
endedAt: TimedSession.endedAt,
|
||||||
|
createdAt: TimedSession.createdAt,
|
||||||
|
issueNumber: Issue.number,
|
||||||
|
projectKey: Project.key,
|
||||||
|
})
|
||||||
|
.from(TimedSession)
|
||||||
|
.innerJoin(Issue, eq(TimedSession.issueId, Issue.id))
|
||||||
|
.innerJoin(Project, eq(Issue.projectId, Project.id))
|
||||||
|
.where(and(eq(TimedSession.userId, userId), isNull(TimedSession.endedAt)))
|
||||||
|
.orderBy(desc(TimedSession.createdAt));
|
||||||
|
return timedSessions;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getCompletedTimedSessions(userId: number, limit = 50, offset = 0) {
|
export async function getCompletedTimedSessions(userId: number, limit = 50, offset = 0) {
|
||||||
const timedSessions = await db
|
const timedSessions = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const main = async () => {
|
|||||||
"/user/upload-avatar": withGlobal(withAuth(withCSRF(routes.userUploadAvatar))),
|
"/user/upload-avatar": withGlobal(withAuth(withCSRF(routes.userUploadAvatar))),
|
||||||
|
|
||||||
"/issue/create": withGlobal(withAuth(withCSRF(routes.issueCreate))),
|
"/issue/create": withGlobal(withAuth(withCSRF(routes.issueCreate))),
|
||||||
|
"/issue/by-id": withGlobal(withAuth(routes.issueById)),
|
||||||
"/issue/update": withGlobal(withAuth(withCSRF(routes.issueUpdate))),
|
"/issue/update": withGlobal(withAuth(withCSRF(routes.issueUpdate))),
|
||||||
"/issue/delete": withGlobal(withAuth(withCSRF(routes.issueDelete))),
|
"/issue/delete": withGlobal(withAuth(withCSRF(routes.issueDelete))),
|
||||||
"/issue-comment/create": withGlobal(withAuth(withCSRF(routes.issueCommentCreate))),
|
"/issue-comment/create": withGlobal(withAuth(withCSRF(routes.issueCommentCreate))),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import authLogin from "./auth/login";
|
|||||||
import authLogout from "./auth/logout";
|
import authLogout from "./auth/logout";
|
||||||
import authMe from "./auth/me";
|
import authMe from "./auth/me";
|
||||||
import authRegister from "./auth/register";
|
import authRegister from "./auth/register";
|
||||||
|
import issueById from "./issue/by-id";
|
||||||
import issueCreate from "./issue/create";
|
import issueCreate from "./issue/create";
|
||||||
import issueDelete from "./issue/delete";
|
import issueDelete from "./issue/delete";
|
||||||
import issueUpdate from "./issue/update";
|
import issueUpdate from "./issue/update";
|
||||||
@@ -56,6 +57,7 @@ export const routes = {
|
|||||||
userUploadAvatar,
|
userUploadAvatar,
|
||||||
|
|
||||||
issueCreate,
|
issueCreate,
|
||||||
|
issueById,
|
||||||
issueDelete,
|
issueDelete,
|
||||||
issueUpdate,
|
issueUpdate,
|
||||||
|
|
||||||
|
|||||||
29
packages/backend/src/routes/issue/by-id.ts
Normal file
29
packages/backend/src/routes/issue/by-id.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { IssueByIdQuerySchema } from "@sprint/shared";
|
||||||
|
import type { AuthedRequest } from "../../auth/middleware";
|
||||||
|
import { getIssueOrganisationId, getIssueWithUsersById, getOrganisationMemberRole } from "../../db/queries";
|
||||||
|
import { errorResponse, parseQueryParams } from "../../validation";
|
||||||
|
|
||||||
|
export default async function issueById(req: AuthedRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const parsed = parseQueryParams(url, IssueByIdQuerySchema);
|
||||||
|
if ("error" in parsed) return parsed.error;
|
||||||
|
|
||||||
|
const { issueId } = parsed.data;
|
||||||
|
|
||||||
|
const organisationId = await getIssueOrganisationId(issueId);
|
||||||
|
if (!organisationId) {
|
||||||
|
return errorResponse(`organisation not found for issue ${issueId}`, "ORG_NOT_FOUND", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await getOrganisationMemberRole(organisationId, req.userId);
|
||||||
|
if (!member) {
|
||||||
|
return errorResponse("forbidden", "FORBIDDEN", 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issue = await getIssueWithUsersById(issueId);
|
||||||
|
if (!issue) {
|
||||||
|
return errorResponse(`issue not found: ${issueId}`, "ISSUE_NOT_FOUND", 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response.json(issue);
|
||||||
|
}
|
||||||
@@ -1,15 +1,34 @@
|
|||||||
import { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@sprint/shared";
|
import { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@sprint/shared";
|
||||||
import type { AuthedRequest } from "../auth/middleware";
|
import type { AuthedRequest } from "../auth/middleware";
|
||||||
import { getUserTimedSessions } from "../db/queries";
|
import { getActiveTimedSessionsWithIssue, getUserTimedSessions } from "../db/queries";
|
||||||
|
|
||||||
// GET /timers?limit=50&offset=0
|
// GET /timers?limit=50&offset=0
|
||||||
export default async function timers(req: AuthedRequest) {
|
export default async function timers(req: AuthedRequest) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
const limitParam = url.searchParams.get("limit");
|
const limitParam = url.searchParams.get("limit");
|
||||||
const offsetParam = url.searchParams.get("offset");
|
const offsetParam = url.searchParams.get("offset");
|
||||||
|
const activeOnlyParam = url.searchParams.get("activeOnly");
|
||||||
|
|
||||||
const limit = limitParam ? Number(limitParam) : 50;
|
const limit = limitParam ? Number(limitParam) : 50;
|
||||||
const offset = offsetParam ? Number(offsetParam) : 0;
|
const offset = offsetParam ? Number(offsetParam) : 0;
|
||||||
|
const activeOnly = activeOnlyParam === "true" || activeOnlyParam === "1";
|
||||||
|
|
||||||
|
if (activeOnly) {
|
||||||
|
const sessions = await getActiveTimedSessionsWithIssue(req.userId);
|
||||||
|
const enriched = sessions.map((session) => ({
|
||||||
|
id: session.id,
|
||||||
|
issueId: session.issueId,
|
||||||
|
issueNumber: session.issueNumber,
|
||||||
|
projectKey: session.projectKey,
|
||||||
|
timestamps: session.timestamps,
|
||||||
|
endedAt: session.endedAt,
|
||||||
|
workTimeMs: calculateWorkTimeMs(session.timestamps),
|
||||||
|
breakTimeMs: calculateBreakTimeMs(session.timestamps),
|
||||||
|
isRunning: isTimerRunning(session.timestamps),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return Response.json(enriched);
|
||||||
|
}
|
||||||
|
|
||||||
const sessions = await getUserTimedSessions(req.userId, limit, offset);
|
const sessions = await getUserTimedSessions(req.userId, limit, offset);
|
||||||
|
|
||||||
|
|||||||
94
packages/frontend/src/components/active-timers-overlay.tsx
Normal file
94
packages/frontend/src/components/active-timers-overlay.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { IssueModal } from "@/components/issue-modal";
|
||||||
|
import { useSessionSafe } from "@/components/session-provider";
|
||||||
|
import { TimerControls } from "@/components/timer-controls";
|
||||||
|
import { getWorkTimeMs } from "@/components/timer-display";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useActiveTimers, useInactiveTimers, useIssueById } from "@/lib/query/hooks";
|
||||||
|
import { issueID } from "@/lib/utils";
|
||||||
|
|
||||||
|
const REFRESH_INTERVAL_MS = 10000;
|
||||||
|
|
||||||
|
export function ActiveTimersOverlay() {
|
||||||
|
const session = useSessionSafe();
|
||||||
|
const { data: activeTimers = [] } = useActiveTimers({
|
||||||
|
refetchInterval: REFRESH_INTERVAL_MS,
|
||||||
|
enabled: Boolean(session?.user),
|
||||||
|
});
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
|
const hasRunning = useMemo(() => activeTimers.some((timer) => timer.isRunning), [activeTimers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasRunning) return;
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setTick((value) => value + 1);
|
||||||
|
}, 1000);
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [hasRunning]);
|
||||||
|
|
||||||
|
void tick;
|
||||||
|
|
||||||
|
if (!session?.user || activeTimers.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-[calc(1rem+env(safe-area-inset-bottom))] left-[calc(1rem+env(safe-area-inset-left))] z-50 flex flex-col gap-2">
|
||||||
|
{activeTimers.map((timer) => (
|
||||||
|
<ActiveTimerItem key={timer.id} timer={timer} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActiveTimerItem({
|
||||||
|
timer,
|
||||||
|
}: {
|
||||||
|
timer: {
|
||||||
|
id: number;
|
||||||
|
issueId: number;
|
||||||
|
issueNumber: number;
|
||||||
|
projectKey: string;
|
||||||
|
timestamps: string[];
|
||||||
|
isRunning: boolean;
|
||||||
|
};
|
||||||
|
}) {
|
||||||
|
const { data: issueData } = useIssueById(timer.issueId);
|
||||||
|
const { data: inactiveTimers = [] } = useInactiveTimers(timer.issueId, { refetchInterval: 10000 });
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const issueKey = issueID(timer.projectKey, timer.issueNumber);
|
||||||
|
|
||||||
|
const inactiveWorkTimeMs = inactiveTimers.reduce(
|
||||||
|
(total, session) => total + getWorkTimeMs(session?.timestamps),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const currentWorkTimeMs = getWorkTimeMs(timer.timestamps);
|
||||||
|
const totalWorkTimeMs = inactiveWorkTimeMs + currentWorkTimeMs;
|
||||||
|
|
||||||
|
const issueKeyNode = issueData ? (
|
||||||
|
<IssueModal
|
||||||
|
issueData={issueData}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
trigger={
|
||||||
|
<Button variant="link" size="none" className="text-sm tabular-nums truncate min-w-0 font-700">
|
||||||
|
{issueKey}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button variant="link" size="none" className="text-sm tabular-nums truncate min-w-0 font-700" disabled>
|
||||||
|
{issueKey}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimerControls
|
||||||
|
issueId={timer.issueId}
|
||||||
|
issueKey={issueKeyNode}
|
||||||
|
timestamps={timer.timestamps}
|
||||||
|
isRunning={timer.isRunning}
|
||||||
|
totalTimeMs={totalWorkTimeMs}
|
||||||
|
className="border bg-background/95 pl-2 pr-1 py-1"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,8 +9,8 @@ import SmallUserDisplay from "@/components/small-user-display";
|
|||||||
import { SprintSelect } from "@/components/sprint-select";
|
import { SprintSelect } from "@/components/sprint-select";
|
||||||
import { StatusSelect } from "@/components/status-select";
|
import { StatusSelect } from "@/components/status-select";
|
||||||
import StatusTag from "@/components/status-tag";
|
import StatusTag from "@/components/status-tag";
|
||||||
import { TimerDisplay } from "@/components/timer-display";
|
import { TimerControls } from "@/components/timer-controls";
|
||||||
import { TimerModal } from "@/components/timer-modal";
|
import { getWorkTimeMs } from "@/components/timer-display";
|
||||||
import { TypeSelect } from "@/components/type-select";
|
import { TypeSelect } from "@/components/type-select";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||||
@@ -19,7 +19,13 @@ import { IconButton } from "@/components/ui/icon-button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { SelectTrigger } from "@/components/ui/select";
|
import { SelectTrigger } from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useDeleteIssue, useSelectedOrganisation, useUpdateIssue } from "@/lib/query/hooks";
|
import {
|
||||||
|
useDeleteIssue,
|
||||||
|
useInactiveTimers,
|
||||||
|
useSelectedOrganisation,
|
||||||
|
useTimerState,
|
||||||
|
useUpdateIssue,
|
||||||
|
} from "@/lib/query/hooks";
|
||||||
import { parseError } from "@/lib/server";
|
import { parseError } from "@/lib/server";
|
||||||
import { cn, issueID } from "@/lib/utils";
|
import { cn, issueID } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -55,6 +61,9 @@ export function IssueDetails({
|
|||||||
const organisation = useSelectedOrganisation();
|
const organisation = useSelectedOrganisation();
|
||||||
const updateIssue = useUpdateIssue();
|
const updateIssue = useUpdateIssue();
|
||||||
const deleteIssue = useDeleteIssue();
|
const deleteIssue = useDeleteIssue();
|
||||||
|
const { data: timerState } = useTimerState(issueData.Issue.id, { refetchInterval: 10000 });
|
||||||
|
const { data: inactiveTimers = [] } = useInactiveTimers(issueData.Issue.id, { refetchInterval: 10000 });
|
||||||
|
const [timerTick, setTimerTick] = useState(0);
|
||||||
|
|
||||||
const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
|
const [assigneeIds, setAssigneeIds] = useState<string[]>([]);
|
||||||
const [sprintId, setSprintId] = useState<string>("unassigned");
|
const [sprintId, setSprintId] = useState<string>("unassigned");
|
||||||
@@ -95,6 +104,16 @@ export function IssueDetails({
|
|||||||
setIsEditingDescription(false);
|
setIsEditingDescription(false);
|
||||||
}, [issueData]);
|
}, [issueData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!timerState?.isRunning) return;
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
setTimerTick((value) => value + 1);
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [timerState?.isRunning]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (copyTimeoutRef.current) {
|
if (copyTimeoutRef.current) {
|
||||||
@@ -103,6 +122,15 @@ export function IssueDetails({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
void timerTick;
|
||||||
|
|
||||||
|
const inactiveWorkTimeMs = inactiveTimers.reduce(
|
||||||
|
(total, session) => total + getWorkTimeMs(session?.timestamps),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const currentWorkTimeMs = getWorkTimeMs(timerState?.timestamps);
|
||||||
|
const totalWorkTimeMs = inactiveWorkTimeMs + currentWorkTimeMs;
|
||||||
|
|
||||||
const handleSprintChange = async (value: string) => {
|
const handleSprintChange = async (value: string) => {
|
||||||
setSprintId(value);
|
setSprintId(value);
|
||||||
const newSprintId = value === "unassigned" ? null : Number(value);
|
const newSprintId = value === "unassigned" ? null : Number(value);
|
||||||
@@ -486,10 +514,17 @@ export function IssueDetails({
|
|||||||
|
|
||||||
{organisation?.Organisation.features.issueTimeTracking && isAssignee && (
|
{organisation?.Organisation.features.issueTimeTracking && isAssignee && (
|
||||||
<div className={cn("flex flex-col gap-2", hasMultipleAssignees && "cursor-not-allowed")}>
|
<div className={cn("flex flex-col gap-2", hasMultipleAssignees && "cursor-not-allowed")}>
|
||||||
<div className="flex items-center gap-2">
|
<TimerControls
|
||||||
<TimerModal issueId={issueData.Issue.id} disabled={hasMultipleAssignees} />
|
issueId={issueData.Issue.id}
|
||||||
<TimerDisplay issueId={issueData.Issue.id} />
|
// biome-ignore lint/complexity/noUselessFragments: <needed to represent absence>
|
||||||
</div>
|
issueKey={<></>}
|
||||||
|
timestamps={timerState?.timestamps}
|
||||||
|
isRunning={timerState?.isRunning}
|
||||||
|
totalTimeMs={totalWorkTimeMs}
|
||||||
|
disabled={hasMultipleAssignees}
|
||||||
|
size="sm"
|
||||||
|
className="self-start w-fit border bg-background/95 pl-0 pr-1 py-1"
|
||||||
|
/>
|
||||||
{hasMultipleAssignees && (
|
{hasMultipleAssignees && (
|
||||||
<span className="text-xs text-destructive/85 font-600">
|
<span className="text-xs text-destructive/85 font-600">
|
||||||
Timers cannot be used on issues with multiple assignees
|
Timers cannot be used on issues with multiple assignees
|
||||||
|
|||||||
105
packages/frontend/src/components/timer-controls.tsx
Normal file
105
packages/frontend/src/components/timer-controls.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { getWorkTimeMs } from "@/components/timer-display";
|
||||||
|
import Icon from "@/components/ui/icon";
|
||||||
|
import { IconButton } from "@/components/ui/icon-button";
|
||||||
|
import { useEndTimer, useToggleTimer } from "@/lib/query/hooks";
|
||||||
|
import { parseError } from "@/lib/server";
|
||||||
|
import { cn, formatTime } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function TimerControls({
|
||||||
|
issueId,
|
||||||
|
issueKey,
|
||||||
|
timestamps,
|
||||||
|
isRunning,
|
||||||
|
totalTimeMs,
|
||||||
|
disabled = false,
|
||||||
|
size = "md",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
issueId: number;
|
||||||
|
issueKey: ReactNode;
|
||||||
|
timestamps?: string[] | null;
|
||||||
|
isRunning?: boolean;
|
||||||
|
totalTimeMs?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
size?: "sm" | "md";
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const toggleTimer = useToggleTimer();
|
||||||
|
const endTimer = useEndTimer();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const elapsedMs = getWorkTimeMs(timestamps ?? undefined);
|
||||||
|
const resolvedTotalMs = totalTimeMs ?? elapsedMs;
|
||||||
|
const running = Boolean(isRunning);
|
||||||
|
const hasTimer = (timestamps?.length ?? 0) > 0;
|
||||||
|
|
||||||
|
const handleToggle = async () => {
|
||||||
|
if (disabled) return;
|
||||||
|
try {
|
||||||
|
await toggleTimer.mutateAsync({ issueId });
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(parseError(err as Error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEnd = async () => {
|
||||||
|
if (disabled) return;
|
||||||
|
try {
|
||||||
|
await endTimer.mutateAsync({ issueId });
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(parseError(err as Error));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isCompact = size === "sm";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-1", className)}>
|
||||||
|
<div className={cn("flex items-center", isCompact ? "gap-2" : "gap-3")}>
|
||||||
|
<div className="min-w-0 flex items-center">{issueKey}</div>
|
||||||
|
<span className={cn("font-mono tabular-nums leading-none", isCompact ? "text-xs" : "text-sm")}>
|
||||||
|
{formatTime(elapsedMs)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-mono tabular-nums leading-none text-muted-foreground",
|
||||||
|
isCompact ? "text-[11.5px] font-300" : "text-xs font-300",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
({formatTime(resolvedTotalMs)})
|
||||||
|
</span>
|
||||||
|
<div className={cn("ml-auto flex items-center", isCompact ? "gap-1" : "gap-2")}>
|
||||||
|
<IconButton
|
||||||
|
size={"sm"}
|
||||||
|
variant="dummy"
|
||||||
|
aria-label={running ? "Pause timer" : "Resume timer"}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={"hover:opacity-70"}
|
||||||
|
>
|
||||||
|
{running ? (
|
||||||
|
<Icon icon="pause" size={isCompact ? 14 : 16} />
|
||||||
|
) : (
|
||||||
|
<Icon icon="play" size={isCompact ? 14 : 16} />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size={"sm"}
|
||||||
|
variant="destructive"
|
||||||
|
aria-label="End timer"
|
||||||
|
disabled={disabled || !hasTimer}
|
||||||
|
onClick={handleEnd}
|
||||||
|
className={"hover:opacity-70"}
|
||||||
|
>
|
||||||
|
<Icon icon="stop" size={isCompact ? 14 : 16} color={"var(--destructive)"} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{error && <span className="mt-1 block text-xs text-destructive">{error}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ const iconButtonVariants = cva(
|
|||||||
outline: "border bg-transparent dark:hover:bg-muted/40",
|
outline: "border bg-transparent dark:hover:bg-muted/40",
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
dummy: "",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: "w-6 h-6",
|
default: "w-6 h-6",
|
||||||
|
|||||||
@@ -19,8 +19,11 @@ import {
|
|||||||
Moon as PixelMoon,
|
Moon as PixelMoon,
|
||||||
MoreVertical as PixelMoreVertical,
|
MoreVertical as PixelMoreVertical,
|
||||||
NoteDelete as PixelNoteDelete,
|
NoteDelete as PixelNoteDelete,
|
||||||
|
Pause as PixelPause,
|
||||||
|
Play as PixelPlay,
|
||||||
Plus as PixelPlus,
|
Plus as PixelPlus,
|
||||||
Server as PixelServer,
|
Server as PixelServer,
|
||||||
|
CheckboxOn as PixelStop,
|
||||||
Sun as PixelSun,
|
Sun as PixelSun,
|
||||||
Trash as PixelTrash,
|
Trash as PixelTrash,
|
||||||
Undo as PixelUndo,
|
Undo as PixelUndo,
|
||||||
@@ -50,9 +53,12 @@ import {
|
|||||||
SignOutIcon as PhosphorLogOut,
|
SignOutIcon as PhosphorLogOut,
|
||||||
MoonIcon as PhosphorMoon,
|
MoonIcon as PhosphorMoon,
|
||||||
OctagonIcon as PhosphorOctagon,
|
OctagonIcon as PhosphorOctagon,
|
||||||
|
PauseIcon as PhosphorPause,
|
||||||
|
PlayIcon as PhosphorPlay,
|
||||||
PlusIcon as PhosphorPlus,
|
PlusIcon as PhosphorPlus,
|
||||||
QuestionIcon as PhosphorQuestion,
|
QuestionIcon as PhosphorQuestion,
|
||||||
HardDrivesIcon as PhosphorServer,
|
HardDrivesIcon as PhosphorServer,
|
||||||
|
StopIcon as PhosphorStop,
|
||||||
SunIcon as PhosphorSun,
|
SunIcon as PhosphorSun,
|
||||||
TrashIcon as PhosphorTrash,
|
TrashIcon as PhosphorTrash,
|
||||||
ArrowCounterClockwiseIcon as PhosphorUndo,
|
ArrowCounterClockwiseIcon as PhosphorUndo,
|
||||||
@@ -87,8 +93,11 @@ import {
|
|||||||
Home as LucideHome,
|
Home as LucideHome,
|
||||||
Moon,
|
Moon,
|
||||||
OctagonXIcon,
|
OctagonXIcon,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
Plus,
|
Plus,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
|
Square,
|
||||||
SquareCheck,
|
SquareCheck,
|
||||||
Sun,
|
Sun,
|
||||||
Timer,
|
Timer,
|
||||||
@@ -103,6 +112,7 @@ import { useSessionSafe } from "@/components/session-provider";
|
|||||||
|
|
||||||
// lucide: https://lucide.dev/icons
|
// lucide: https://lucide.dev/icons
|
||||||
// pixel: https://pixelarticons.com/ (CLICK "Legacy") - these ones are free
|
// pixel: https://pixelarticons.com/ (CLICK "Legacy") - these ones are free
|
||||||
|
// phosphor: https://phosphoricons.com/
|
||||||
const icons = {
|
const icons = {
|
||||||
alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning },
|
alertTriangle: { lucide: AlertTriangle, pixel: PixelAlert, phosphor: PhosphorWarning },
|
||||||
bug: { lucide: Bug, pixel: PixelDebug, phosphor: PhosphorBug },
|
bug: { lucide: Bug, pixel: PixelDebug, phosphor: PhosphorBug },
|
||||||
@@ -138,9 +148,12 @@ const icons = {
|
|||||||
logOut: { lucide: LogOut, pixel: PixelLogout, phosphor: PhosphorLogOut },
|
logOut: { lucide: LogOut, pixel: PixelLogout, phosphor: PhosphorLogOut },
|
||||||
moon: { lucide: Moon, pixel: PixelMoon, phosphor: PhosphorMoon },
|
moon: { lucide: Moon, pixel: PixelMoon, phosphor: PhosphorMoon },
|
||||||
octagonXIcon: { lucide: OctagonXIcon, pixel: PixelClose, phosphor: PhosphorOctagon },
|
octagonXIcon: { lucide: OctagonXIcon, pixel: PixelClose, phosphor: PhosphorOctagon },
|
||||||
|
pause: { lucide: Pause, pixel: PixelPause, phosphor: PhosphorPause },
|
||||||
|
play: { lucide: Play, pixel: PixelPlay, phosphor: PhosphorPlay },
|
||||||
plus: { lucide: Plus, pixel: PixelPlus, phosphor: PhosphorPlus },
|
plus: { lucide: Plus, pixel: PixelPlus, phosphor: PhosphorPlus },
|
||||||
serverIcon: { lucide: ServerIcon, pixel: PixelServer, phosphor: PhosphorServer },
|
serverIcon: { lucide: ServerIcon, pixel: PixelServer, phosphor: PhosphorServer },
|
||||||
sun: { lucide: Sun, pixel: PixelSun, phosphor: PhosphorSun },
|
sun: { lucide: Sun, pixel: PixelSun, phosphor: PhosphorSun },
|
||||||
|
stop: { lucide: Square, pixel: PixelStop, phosphor: PhosphorStop },
|
||||||
timer: { lucide: Timer, pixel: PixelClock, phosphor: PhosphorClock },
|
timer: { lucide: Timer, pixel: PixelClock, phosphor: PhosphorClock },
|
||||||
trash: { lucide: Trash, pixel: PixelTrash, phosphor: PhosphorTrash },
|
trash: { lucide: Trash, pixel: PixelTrash, phosphor: PhosphorTrash },
|
||||||
triangleAlertIcon: { lucide: TriangleAlertIcon, pixel: PixelAlert, phosphor: PhosphorWarning },
|
triangleAlertIcon: { lucide: TriangleAlertIcon, pixel: PixelAlert, phosphor: PhosphorWarning },
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
IssueByIdQuery,
|
||||||
IssueCreateRequest,
|
IssueCreateRequest,
|
||||||
IssueRecord,
|
IssueRecord,
|
||||||
IssueResponse,
|
IssueResponse,
|
||||||
@@ -21,6 +22,14 @@ export function useIssues(projectId?: number | null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useIssueById(issueId?: IssueByIdQuery["issueId"] | null) {
|
||||||
|
return useQuery<IssueResponse>({
|
||||||
|
queryKey: queryKeys.issues.byId(issueId ?? 0),
|
||||||
|
queryFn: () => issue.byId(issueId ?? 0),
|
||||||
|
enabled: Boolean(issueId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useIssueStatusCount(organisationId?: number | null, status?: string | null) {
|
export function useIssueStatusCount(organisationId?: number | null, status?: string | null) {
|
||||||
return useQuery<StatusCountResponse>({
|
return useQuery<StatusCountResponse>({
|
||||||
queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""),
|
queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""),
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
import type { TimerEndRequest, TimerState, TimerToggleRequest } from "@sprint/shared";
|
import type { TimerEndRequest, TimerListItem, TimerState, TimerToggleRequest } from "@sprint/shared";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "@/lib/query/keys";
|
import { queryKeys } from "@/lib/query/keys";
|
||||||
import { timer } from "@/lib/server";
|
import { timer } from "@/lib/server";
|
||||||
|
|
||||||
|
const activeTimersQueryFn = () => timer.list({ activeOnly: true });
|
||||||
|
|
||||||
|
export function useActiveTimers(options?: { refetchInterval?: number; enabled?: boolean }) {
|
||||||
|
return useQuery<TimerListItem[]>({
|
||||||
|
queryKey: queryKeys.timers.list(),
|
||||||
|
queryFn: activeTimersQueryFn,
|
||||||
|
enabled: options?.enabled ?? true,
|
||||||
|
refetchInterval: options?.refetchInterval,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useTimerState(issueId?: number | null, options?: { refetchInterval?: number }) {
|
export function useTimerState(issueId?: number | null, options?: { refetchInterval?: number }) {
|
||||||
return useQuery<TimerState>({
|
return useQuery<TimerListItem[], Error, TimerState>({
|
||||||
queryKey: queryKeys.timers.active(issueId ?? 0),
|
queryKey: queryKeys.timers.list(),
|
||||||
queryFn: () => timer.get(issueId ?? 0),
|
queryFn: activeTimersQueryFn,
|
||||||
enabled: Boolean(issueId),
|
enabled: Boolean(issueId),
|
||||||
refetchInterval: options?.refetchInterval,
|
refetchInterval: options?.refetchInterval,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
|
select: (timers) => timers.find((timer) => timer.issueId === issueId) ?? null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,9 +42,9 @@ export function useToggleTimer() {
|
|||||||
return useMutation<TimerState, Error, TimerToggleRequest>({
|
return useMutation<TimerState, Error, TimerToggleRequest>({
|
||||||
mutationKey: ["timers", "toggle"],
|
mutationKey: ["timers", "toggle"],
|
||||||
mutationFn: timer.toggle,
|
mutationFn: timer.toggle,
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -42,9 +55,9 @@ export function useEndTimer() {
|
|||||||
return useMutation<TimerState, Error, TimerEndRequest>({
|
return useMutation<TimerState, Error, TimerEndRequest>({
|
||||||
mutationKey: ["timers", "end"],
|
mutationKey: ["timers", "end"],
|
||||||
mutationFn: timer.end,
|
mutationFn: timer.end,
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
queryClient.setQueryData(queryKeys.timers.active(variables.issueId), data);
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.timers.list() });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const queryKeys = {
|
|||||||
issues: {
|
issues: {
|
||||||
all: ["issues"] as const,
|
all: ["issues"] as const,
|
||||||
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const,
|
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const,
|
||||||
|
byId: (issueId: number) => [...queryKeys.issues.all, "by-id", issueId] as const,
|
||||||
statusCount: (organisationId: number, status: string) =>
|
statusCount: (organisationId: number, status: string) =>
|
||||||
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
|
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
|
||||||
typeCount: (organisationId: number, type: string) =>
|
typeCount: (organisationId: number, type: string) =>
|
||||||
@@ -30,7 +31,7 @@ export const queryKeys = {
|
|||||||
all: ["timers"] as const,
|
all: ["timers"] as const,
|
||||||
active: (issueId: number) => [...queryKeys.timers.all, "active", issueId] as const,
|
active: (issueId: number) => [...queryKeys.timers.all, "active", issueId] as const,
|
||||||
inactive: (issueId: number) => [...queryKeys.timers.all, "inactive", issueId] as const,
|
inactive: (issueId: number) => [...queryKeys.timers.all, "inactive", issueId] as const,
|
||||||
list: (issueId: number) => [...queryKeys.timers.all, "list", issueId] as const,
|
list: () => [...queryKeys.timers.all, "list"] as const,
|
||||||
},
|
},
|
||||||
users: {
|
users: {
|
||||||
all: ["users"] as const,
|
all: ["users"] as const,
|
||||||
|
|||||||
19
packages/frontend/src/lib/server/issue/byId.ts
Normal file
19
packages/frontend/src/lib/server/issue/byId.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { IssueByIdQuery, IssueResponse } from "@sprint/shared";
|
||||||
|
import { getServerURL } from "@/lib/utils";
|
||||||
|
import { getErrorMessage } from "..";
|
||||||
|
|
||||||
|
export async function byId(issueId: IssueByIdQuery["issueId"]): Promise<IssueResponse> {
|
||||||
|
const url = new URL(`${getServerURL()}/issue/by-id`);
|
||||||
|
url.searchParams.set("issueId", `${issueId}`);
|
||||||
|
|
||||||
|
const res = await fetch(url.toString(), {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const message = await getErrorMessage(res, `failed to get issue (${res.status})`);
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export { byId } from "@/lib/server/issue/byId";
|
||||||
export { byProject } from "@/lib/server/issue/byProject";
|
export { byProject } from "@/lib/server/issue/byProject";
|
||||||
export { create } from "@/lib/server/issue/create";
|
export { create } from "@/lib/server/issue/create";
|
||||||
export { remove as delete } from "@/lib/server/issue/delete";
|
export { remove as delete } from "@/lib/server/issue/delete";
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
|
import type { TimerListItem } from "@sprint/shared";
|
||||||
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
import { getCsrfToken, getServerURL } from "@/lib/utils";
|
||||||
import { getErrorMessage } from "..";
|
import { getErrorMessage } from "..";
|
||||||
|
|
||||||
type TimerListInput = {
|
type TimerListInput = {
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
|
activeOnly?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function list(input: TimerListInput = {}): Promise<unknown> {
|
export async function list(input: TimerListInput = {}): Promise<TimerListItem[]> {
|
||||||
const url = new URL(`${getServerURL()}/timers`);
|
const url = new URL(`${getServerURL()}/timers`);
|
||||||
if (input.limit != null) url.searchParams.set("limit", `${input.limit}`);
|
if (input.limit != null) url.searchParams.set("limit", `${input.limit}`);
|
||||||
if (input.offset != null) url.searchParams.set("offset", `${input.offset}`);
|
if (input.offset != null) url.searchParams.set("offset", `${input.offset}`);
|
||||||
|
if (input.activeOnly != null) url.searchParams.set("activeOnly", input.activeOnly ? "true" : "false");
|
||||||
|
|
||||||
const csrfToken = getCsrfToken();
|
const csrfToken = getCsrfToken();
|
||||||
const headers: HeadersInit = {};
|
const headers: HeadersInit = {};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import "./App.css";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
import { BrowserRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { ActiveTimersOverlay } from "@/components/active-timers-overlay";
|
||||||
import { QueryProvider } from "@/components/query-provider";
|
import { QueryProvider } from "@/components/query-provider";
|
||||||
import { SelectionProvider } from "@/components/selection-provider";
|
import { SelectionProvider } from "@/components/selection-provider";
|
||||||
import { RequireAuth, SessionProvider } from "@/components/session-provider";
|
import { RequireAuth, SessionProvider } from "@/components/session-provider";
|
||||||
@@ -57,6 +58,7 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
|||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
<ActiveTimersOverlay />
|
||||||
<Toaster visibleToasts={1} duration={2000} />
|
<Toaster visibleToasts={1} duration={2000} />
|
||||||
</SelectionProvider>
|
</SelectionProvider>
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ export const IssuesByProjectQuerySchema = z.object({
|
|||||||
|
|
||||||
export type IssuesByProjectQuery = z.infer<typeof IssuesByProjectQuerySchema>;
|
export type IssuesByProjectQuery = z.infer<typeof IssuesByProjectQuerySchema>;
|
||||||
|
|
||||||
|
export const IssueByIdQuerySchema = z.object({
|
||||||
|
issueId: z.coerce.number().int().positive("issueId must be a positive integer"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type IssueByIdQuery = z.infer<typeof IssueByIdQuerySchema>;
|
||||||
|
|
||||||
export const IssuesStatusCountQuerySchema = z.object({
|
export const IssuesStatusCountQuerySchema = z.object({
|
||||||
organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"),
|
organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"),
|
||||||
status: z.string().min(1, "Status is required").max(ISSUE_STATUS_MAX_LENGTH),
|
status: z.string().min(1, "Status is required").max(ISSUE_STATUS_MAX_LENGTH),
|
||||||
@@ -512,6 +518,24 @@ export const TimerStateSchema = z
|
|||||||
|
|
||||||
export type TimerStateType = z.infer<typeof TimerStateSchema>;
|
export type TimerStateType = z.infer<typeof TimerStateSchema>;
|
||||||
|
|
||||||
|
export const TimerListItemSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
issueId: z.number(),
|
||||||
|
issueNumber: z.number(),
|
||||||
|
projectKey: z.string(),
|
||||||
|
workTimeMs: z.number(),
|
||||||
|
breakTimeMs: z.number(),
|
||||||
|
isRunning: z.boolean(),
|
||||||
|
timestamps: z.array(z.string()),
|
||||||
|
endedAt: z.string().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TimerListItem = z.infer<typeof TimerListItemSchema>;
|
||||||
|
|
||||||
|
export const TimerListResponseSchema = z.array(TimerListItemSchema);
|
||||||
|
|
||||||
|
export type TimerListResponse = z.infer<typeof TimerListResponseSchema>;
|
||||||
|
|
||||||
export const StatusCountResponseSchema = z.array(
|
export const StatusCountResponseSchema = z.array(
|
||||||
z.object({
|
z.object({
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export type {
|
export type {
|
||||||
ApiError,
|
ApiError,
|
||||||
AuthResponse,
|
AuthResponse,
|
||||||
|
IssueByIdQuery,
|
||||||
IssueCommentCreateRequest,
|
IssueCommentCreateRequest,
|
||||||
IssueCommentDeleteRequest,
|
IssueCommentDeleteRequest,
|
||||||
IssueCommentResponseType,
|
IssueCommentResponseType,
|
||||||
@@ -43,6 +44,8 @@ export type {
|
|||||||
SuccessResponse,
|
SuccessResponse,
|
||||||
TimerEndRequest,
|
TimerEndRequest,
|
||||||
TimerGetQuery,
|
TimerGetQuery,
|
||||||
|
TimerListItem,
|
||||||
|
TimerListResponse,
|
||||||
TimerStateType,
|
TimerStateType,
|
||||||
TimerToggleRequest,
|
TimerToggleRequest,
|
||||||
TypeCountResponse,
|
TypeCountResponse,
|
||||||
@@ -54,6 +57,7 @@ export type {
|
|||||||
export {
|
export {
|
||||||
ApiErrorSchema,
|
ApiErrorSchema,
|
||||||
AuthResponseSchema,
|
AuthResponseSchema,
|
||||||
|
IssueByIdQuerySchema,
|
||||||
IssueCommentCreateRequestSchema,
|
IssueCommentCreateRequestSchema,
|
||||||
IssueCommentDeleteRequestSchema,
|
IssueCommentDeleteRequestSchema,
|
||||||
IssueCommentRecordSchema,
|
IssueCommentRecordSchema,
|
||||||
@@ -101,6 +105,8 @@ export {
|
|||||||
SuccessResponseSchema,
|
SuccessResponseSchema,
|
||||||
TimerEndRequestSchema,
|
TimerEndRequestSchema,
|
||||||
TimerGetQuerySchema,
|
TimerGetQuerySchema,
|
||||||
|
TimerListItemSchema,
|
||||||
|
TimerListResponseSchema,
|
||||||
TimerStateSchema,
|
TimerStateSchema,
|
||||||
TimerToggleRequestSchema,
|
TimerToggleRequestSchema,
|
||||||
TypeCountResponseSchema,
|
TypeCountResponseSchema,
|
||||||
|
|||||||
Reference in New Issue
Block a user