diff --git a/src/api/redmine/hooks/useRedmineProjectTimeEntryActivities.ts b/src/api/redmine/hooks/useRedmineProjectTimeEntryActivities.ts index c1fa9da..1c71abe 100644 --- a/src/api/redmine/hooks/useRedmineProjectTimeEntryActivities.ts +++ b/src/api/redmine/hooks/useRedmineProjectTimeEntryActivities.ts @@ -1,6 +1,7 @@ import { redmineProjectQuery } from "@/api/redmine/queries/projects"; import { redmineTimeEntryActivitiesQuery } from "@/api/redmine/queries/timeEntryActivities"; -import { useQuery } from "@tanstack/react-query"; +import { TProject, TTimeEntryActivity } from "@/api/redmine/types"; +import { useQueries, useQuery, UseQueryResult } from "@tanstack/react-query"; import { useRedmineApi } from "../../../provider/RedmineApiProvider"; export const useRedmineProjectTimeEntryActivities = (projectId: number) => { @@ -9,13 +10,7 @@ export const useRedmineProjectTimeEntryActivities = (projectId: number) => { const projectQuery = useQuery(redmineProjectQuery(redmineApi, projectId)); const systemTimeEntryActivitiesQuery = useQuery(redmineTimeEntryActivitiesQuery(redmineApi)); - const projectTimeEntryActivities = projectQuery.data?.time_entry_activities; - const activities = projectTimeEntryActivities - ? projectTimeEntryActivities?.map((activity) => ({ - ...systemTimeEntryActivitiesQuery.data?.find((systemActivity) => systemActivity.id === activity.id), - ...activity, - })) - : systemTimeEntryActivitiesQuery.data?.filter((activity) => activity.active !== false); + const activities = resolveProjectTimeEntryActivities(projectQuery.data, systemTimeEntryActivitiesQuery.data); return { activities, @@ -23,3 +18,43 @@ export const useRedmineProjectTimeEntryActivities = (projectId: number) => { isPending: projectQuery.isPending || systemTimeEntryActivitiesQuery.isPending, }; }; + +export const useRedmineMultipleProjectTimeEntryActivities = (projectIds: number[]) => { + const redmineApi = useRedmineApi(); + + const projectsQueries = useQueries({ + queries: projectIds.map((id) => redmineProjectQuery(redmineApi, id)), + combine: combineMultipleProjects, + }); + const systemTimeEntryActivitiesQuery = useQuery(redmineTimeEntryActivitiesQuery(redmineApi)); + + const projectTimeEntryActivitiesMap = Object.fromEntries( + projectIds.map((projectId) => { + const activities = resolveProjectTimeEntryActivities(projectsQueries.data[projectId], systemTimeEntryActivitiesQuery.data); + return [projectId, { activities, defaultActivity: activities?.find((activity) => activity.is_default) }]; + }) + ); + + return { + projectTimeEntryActivitiesMap, + }; +}; + +const combineMultipleProjects = (results: UseQueryResult[]) => ({ + data: results.reduce>((result, query) => { + if (query.data) { + result[query.data.id] = query.data; + } + return result; + }, {}), +}); + +const resolveProjectTimeEntryActivities = (project: TProject | undefined, systemActivities: TTimeEntryActivity[] | undefined) => { + const projectTimeEntryActivities = project?.time_entry_activities; + return projectTimeEntryActivities + ? projectTimeEntryActivities?.map((activity) => ({ + ...systemActivities?.find((systemActivity) => systemActivity.id === activity.id), + ...activity, + })) + : systemActivities?.filter((activity) => activity.active !== false); +}; diff --git a/src/components/time-entry/TimeEntry.tsx b/src/components/time-entry/TimeEntry.tsx index ffeeb55..e726d67 100644 --- a/src/components/time-entry/TimeEntry.tsx +++ b/src/components/time-entry/TimeEntry.tsx @@ -1,4 +1,5 @@ import { TimeEntryContextMenu } from "@/components/time-entry/TimeEntryContextMenu"; +import { clsx } from "clsx"; import { Fragment } from "react"; import { TTimeEntry } from "../../api/redmine/types"; import useFormatHours from "../../hooks/useFormatHours"; @@ -7,27 +8,42 @@ import TimeEntryTooltip from "./TimeEntryTooltip"; type PropTypes = { entries: TTimeEntry[]; - previewHours?: number; - maxDayHours?: number; + preview?: { + hours: number; + name?: string; + }[]; + size?: "sm" | "md" | "lg"; withContextMenu?: boolean; }; -const TimeEntry = ({ entries, previewHours, maxDayHours = 24, withContextMenu = false }: PropTypes) => { +const TimeEntry = ({ entries, preview, size = "sm", withContextMenu = false }: PropTypes) => { const formatHours = useFormatHours(); const sumHours = entries.reduce((sum, entry) => sum + entry.hours, 0); + const sumPreviewHours = preview?.reduce((sum, p) => sum + p.hours, 0); + const maxHours = Math.max(8, sumHours + (sumPreviewHours ?? 0)); return ( -
+
{entries.map((entry) => { const entryElement = (
@@ -44,28 +60,37 @@ const TimeEntry = ({ entries, previewHours, maxDayHours = 24, withContextMenu = ); })} - {!!previewHours && ( - + {preview?.map((p, index) => ( + } /> - -

{formatHours(previewHours)}

+ +

{formatHours(p.hours)}

+ {p.name &&

{p.name}

}
- )} + ))}
diff --git a/src/components/time-entry/TimeEntryOverview.tsx b/src/components/time-entry/TimeEntryOverview.tsx index 7d3e179..604b1d9 100644 --- a/src/components/time-entry/TimeEntryOverview.tsx +++ b/src/components/time-entry/TimeEntryOverview.tsx @@ -43,11 +43,6 @@ export const TimeEntryOverview = ({ from, to, entries }: PropTypes) => { (date: Date) => groupedByDate.get(formatISO(date, { representation: "date" })) ?? ({ date, entries: [], hours: 0 } satisfies GroupedTimeEntries) ); - const maxDayHours = Math.max( - groupedByDate.values().reduce((max, { hours }) => Math.max(max, hours), 0), - 8 - ); - const totalHours = entries.reduce((sum, entry) => sum + entry.hours, 0); const isMoreThanOneWeek = !isSameWeek(from, to, { weekStartsOn: 1 }); @@ -87,7 +82,7 @@ export const TimeEntryOverview = ({ from, to, entries }: PropTypes) => { {isDisabled ? "–" : formatHours(roundHours(hours))}
- +
); diff --git a/src/components/time-entry/TimeEntryPreview.tsx b/src/components/time-entry/TimeEntryPreview.tsx index 6fbbf0e..59cb567 100644 --- a/src/components/time-entry/TimeEntryPreview.tsx +++ b/src/components/time-entry/TimeEntryPreview.tsx @@ -26,7 +26,7 @@ const TimeEntryPreview = ({ date, previewHours, className }: PropTypes) => {

{formatHours(roundHours(sumHours))}

- 12 ? sumHours : 12} /> +
); diff --git a/src/components/timer/ProjectTimersGroup.tsx b/src/components/timer/ProjectTimersGroup.tsx index 6af8e40..d782387 100644 --- a/src/components/timer/ProjectTimersGroup.tsx +++ b/src/components/timer/ProjectTimersGroup.tsx @@ -51,13 +51,13 @@ export const ProjectTimersGroup = ({ projectGroup, className, ...props }: Projec ); }; -const TimerProject = ({ project, type }: { project?: TReference; type: ProjectTimersGroupType["type"] }) => { +export const TimerProject = ({ project, type, forceNoSticky }: { project?: TReference; type: ProjectTimersGroupType["type"]; forceNoSticky?: boolean }) => { const { settings } = useSettings(); return (
diff --git a/src/components/timer/SubmitTimersModal.tsx b/src/components/timer/SubmitTimersModal.tsx new file mode 100644 index 0000000..3e4e329 --- /dev/null +++ b/src/components/timer/SubmitTimersModal.tsx @@ -0,0 +1,297 @@ +/* eslint-disable react/no-children-prop */ +import { useRedmineMultipleProjectTimeEntryActivities } from "@/api/redmine/hooks/useRedmineProjectTimeEntryActivities"; +import { redmineTimeEntriesQueries } from "@/api/redmine/queries/timeEntries"; +import { TCreateTimeEntry, TIssue } from "@/api/redmine/types"; +import ActivityField from "@/components/issue/form/fields/ActivityField"; +import { IssueTitle } from "@/components/issue/IssueTitle"; +import { TimerProject } from "@/components/timer/ProjectTimersGroup"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Form, FormFieldset, FormGrid } from "@/components/ui/form"; +import { useAppForm } from "@/hooks/useAppForm"; +import { calculateTimerTotalElapsedTime, Timer } from "@/hooks/useTimers"; +import { omitUndefinedFilter } from "@/lib/utils"; +import { usePermissions } from "@/provider/PermissionsProvider"; +import { useRedmineApi } from "@/provider/RedmineApiProvider"; +import { useSettings } from "@/provider/SettingsProvider"; +import { useTimerApi } from "@/provider/TimerApiProvider"; +import { roundMillisecondsToInterval } from "@/utils/date"; +import { getErrorMessage } from "@/utils/error"; +import { groupTimers, ProjectTimersGroup } from "@/utils/groupTimers"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import clsx from "clsx"; +import { useState } from "react"; +import { FormattedMessage, useIntl } from "react-intl"; +import { toast } from "sonner"; +import { z } from "zod"; + +type PropTypes = { + timers: Timer[]; + issues: TIssue[]; + onClose: () => void; +}; + +type SubmitTimerGroup = Pick & { + items: TimerItem[]; + invalid: InvalidTimerItem[]; +}; + +type TimerItem = { timer: Timer; issue: TIssue; hours: number; index: number }; + +type InvalidTimerItem = { + timer: Timer; + issue?: TIssue; + reason: "unknown-issue" | "no-permission"; +}; + +const submitTimersFormSchema = ({ formatMessage }: { formatMessage: ReturnType["formatMessage"] }) => + z.object({ + spent_on: z.date(formatMessage({ id: "time.time-entry.field.spent-on.validation.required" })).max(new Date(), formatMessage({ id: "time.time-entry.field.spent-on.validation.in-future" })), + entries: z + .array( + z.discriminatedUnion("_included", [ + z.object({ + _timer_id: z.string(), + _included: z.literal(false), + issue_id: z.int(), + hours: z.number().nullable(), + activity_id: z.int().nullable(), + comments: z.string().nullable(), + }), + z.object({ + _timer_id: z.string(), + _included: z.literal(true), + issue_id: z.int(), + hours: z + .number(formatMessage({ id: "time.time-entry.field.hours.validation.required" })) + .min(0.01, formatMessage({ id: "time.time-entry.field.hours.validation.greater-than-zero" })) + .max(24, formatMessage({ id: "time.time-entry.field.hours.validation.less-than-24" })), + activity_id: z.int(formatMessage({ id: "time.time-entry.field.activity.validation.required" })), + comments: z.string().nullable(), + }), + ]) + ) + .refine((entries) => entries.some((entry) => entry._included), formatMessage({ id: "timers.modal.submit-timers.validation.at-least-one-required" })), + }); + +type TSubmitTimersForm = z.infer>; + +const SubmitTimersModal = ({ timers, issues, onClose }: PropTypes) => { + const { formatMessage } = useIntl(); + const { settings } = useSettings(); + + const redmineApi = useRedmineApi(); + const timerApi = useTimerApi(); + const queryClient = useQueryClient(); + + const { hasProjectPermission } = usePermissions(); + + const [groups] = useState(() => { + let entryIndex = 0; + return groupTimers(timers, issues).map((group) => ({ + key: group.key, + type: group.type, + project: group.project, + ...group.items.reduce( + (acc, { timer, issue }) => { + if (!issue) { + acc.invalid.push({ timer, reason: "unknown-issue" }); + return acc; + } + + if (!hasProjectPermission(issue.project.id, "log_time")) { + acc.invalid.push({ timer, issue, reason: "no-permission" }); + return acc; + } + + const totalElapsedTime = calculateTimerTotalElapsedTime(timer); + const time = settings.features.roundToInterval ? roundMillisecondsToInterval(totalElapsedTime, settings.features.roundingInterval, settings.features.roundingMode) : totalElapsedTime; + const hours = Number((time / 1000 / 60 / 60).toFixed(2)); + acc.items.push({ timer, issue, hours, index: entryIndex++ }); + return acc; + }, + { items: [] as TimerItem[], invalid: [] as InvalidTimerItem[] } + ), + })); + }); + + const projectIds = Array.from(new Set(groups.map(({ project }) => project?.id).filter(omitUndefinedFilter))); + const { projectTimeEntryActivitiesMap } = useRedmineMultipleProjectTimeEntryActivities(projectIds); + + const createTimeEntryMutation = useMutation({ + mutationFn: (entry: TCreateTimeEntry) => redmineApi.createTimeEntry(entry), + }); + const [submitResults, setSubmitResults] = useState>({}); + + const form = useAppForm({ + defaultValues: { + spent_on: new Date(), + entries: groups + .flatMap((group) => group.items) + .map( + ({ timer, issue, hours }) => + ({ + _timer_id: timer.id, + _included: (hours > 0) as true, + issue_id: issue.id, + hours, + activity_id: projectTimeEntryActivitiesMap[issue.project.id]?.defaultActivity?.id, + comments: timer.name ?? null, + }) satisfies Partial> as TSubmitTimersForm["entries"][number] + ), + } satisfies TSubmitTimersForm as TSubmitTimersForm, + validators: { + onChange: submitTimersFormSchema({ formatMessage }), + }, + onSubmit: async ({ value }) => { + let hasError = false; + let submittedCount = 0; + + for (const [index, entry] of value.entries.entries()) { + if (!entry._included) continue; + + try { + const { _timer_id, _included, ...entryData } = entry; + await createTimeEntryMutation.mutateAsync({ + spent_on: value.spent_on, + ...entryData, + }); + } catch (error) { + hasError = true; + setSubmitResults((prev) => ({ ...prev, [entry._timer_id]: { error: getErrorMessage(error) } })); + continue; + } + + submittedCount++; + setSubmitResults((prev) => ({ ...prev, [entry._timer_id]: { submitted: true } })); + form.setFieldValue(`entries[${index}]._included`, false); + await timerApi.deleteTimer({ id: entry._timer_id }); + } + + if (submittedCount > 0) { + queryClient.invalidateQueries(redmineTimeEntriesQueries); + } + + if (!hasError) { + toast.success(formatMessage({ id: "timers.modal.submit-timers.success" }, { count: submittedCount })); + onClose(); + } + }, + }); + + return ( + + + + {formatMessage({ id: "timers.modal.submit-timers.title" })} + {formatMessage({ id: "timers.modal.submit-timers.description" })} + +
+ + ( + + )} + /> + + +
+ {groups.map((projectGroup) => ( +
+ + {projectGroup.items.map((item) => { + const { timer, issue, index } = item; + const submitResult = submitResults[timer.id]; + + if (submitResult?.submitted) return null; + + return ( + +
+ field.handleChange(!!checked)} onBlur={field.handleBlur} />} + /> + +
+ state.values.entries[index]?._included} + children={(included) => ( + + ( + + )} + /> + } + /> + ( + + )} + /> + + )} + /> + {submitResult?.error &&

{submitResult.error}

} +
+ ); + })} + {projectGroup.invalid.map((item) => ( + +
+ + {item.issue ? :

#{item.timer.issueId}

} + + + +
+
+ ))} +
+ ))} +
+ + + state.values.entries.filter((entry) => entry._included).length} + children={(count) => ( + + + + )} + /> + +
+
+
+ ); +}; + +export default SubmitTimersModal; diff --git a/src/components/timer/TimersOverview.tsx b/src/components/timer/TimersOverview.tsx new file mode 100644 index 0000000..b534446 --- /dev/null +++ b/src/components/timer/TimersOverview.tsx @@ -0,0 +1,163 @@ +import { useRedmineCurrentUser } from "@/api/redmine/hooks/useRedmineCurrentUser"; +import { useRedmineTimeEntries } from "@/api/redmine/hooks/useRedmineTimeEntries"; +import { TIssue } from "@/api/redmine/types"; +import TimeEntry from "@/components/time-entry/TimeEntry"; +import SubmitTimersModal from "@/components/timer/SubmitTimersModal"; +import { Button } from "@/components/ui/button"; +import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import useFormatHours from "@/hooks/useFormatHours"; +import { calculateTimerTotalElapsedTime, Timer } from "@/hooks/useTimers"; +import { cn } from "@/lib/utils"; +import { usePermissions } from "@/provider/PermissionsProvider"; +import { formatTimer, roundHours } from "@/utils/date"; +import { useInterval } from "@mantine/hooks"; +import { startOfDay } from "date-fns"; +import { BadgeCheckIcon } from "lucide-react"; +import { ReactNode, useEffect, useEffectEvent, useState } from "react"; +import { useIntl } from "react-intl"; + +type PropTypes = { + timers: Timer[]; + issues: TIssue[]; + className?: string; +}; + +const calculateTotalElapsedTime = (timers: Timer[]) => timers.reduce((sum, timer) => sum + calculateTimerTotalElapsedTime(timer), 0); + +const getGreetingPeriod = () => { + const hour = new Date().getHours(); + if (hour < 5) return "night"; + if (hour < 12) return "morning"; + if (hour < 17) return "afternoon"; + if (hour < 22) return "evening"; + return "night"; +}; + +const TimersOverview = ({ timers, issues, className }: PropTypes) => { + const { formatMessage } = useIntl(); + const formatHours = useFormatHours(); + + const [totalElapsedTime, setTotalElapsedTime] = useState(() => calculateTotalElapsedTime(timers)); + + const updateTotalElapsedTime = useEffectEvent(() => setTotalElapsedTime(calculateTotalElapsedTime(timers))); + // eslint-disable-next-line react-hooks/set-state-in-effect + useEffect(() => updateTotalElapsedTime(), [timers]); + + const activeTimerCount = timers.filter((timer) => timer.activeSession).length; + const interval = useInterval(() => setTotalElapsedTime(calculateTotalElapsedTime(timers)), 1000); + const manageInterval = useEffectEvent((active: boolean) => (active ? interval.start() : interval.stop())); + useEffect(() => manageInterval(activeTimerCount > 0), [activeTimerCount]); + + const { data: me } = useRedmineCurrentUser(); + + const { hasProjectPermission } = usePermissions(); + const timerItems = timers.map((timer) => ({ + timer, + issue: issues.find((issue) => issue.id === timer.issueId), + })); + const canSubmitTimers = timerItems.some(({ timer, issue }) => issue && hasProjectPermission(issue.project.id, "log_time") && calculateTimerTotalElapsedTime(timer) > 0); + + const [isSubmitTimersModalOpen, setIsSubmitTimersModalOpen] = useState(false); + + const today = startOfDay(new Date()); + const myTimeEntriesQuery = useRedmineTimeEntries({ + userId: "me", + from: today, + to: today, + }); + const timeEntries = myTimeEntriesQuery.data ?? []; + const totalTimeEntriesHours = timeEntries.reduce((sum, entry) => sum + entry.hours, 0); + + return ( + <> + + + + {formatMessage({ id: `timers.overview.greeting.period.${getGreetingPeriod()}` })} + {me && {me.firstname?.trim()}} + + + + + + +
+ + +
+ ({ + hours: calculateTimerTotalElapsedTime(timer) / 1000 / 60 / 60, + name: timer.name ?? (issue ? `${issue.tracker.name} #${issue.id} ${issue.subject}` : `#${timer.issueId}`), + }))} + /> +
+
+ + {isSubmitTimersModalOpen && setIsSubmitTimersModalOpen(false)} />} + + ); +}; + +const Stat = ({ + label, + value, + classNames, +}: { + label: string; + value: ReactNode; + classNames?: { + dot?: string; + value?: string; + }; +}) => ( +
+
+ +

{label}

+
+

{value}

+
+); + +const StatSkeleton = () => ( +
+ + +
+); + +export const TimersOverviewSkeleton = ({ className }: { className?: string }) => ( + + + + + + + + + + + +
+ + +
+ +
+
+); + +export default TimersOverview; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index cc2e189..f42080a 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -40,7 +40,7 @@ function FormGrid({ className, cols = 1, ...props }: React.ComponentProps<"div"> function FormFieldset({ className, legend, children, ...props }: React.ComponentProps<"fieldset"> & { legend?: React.ReactNode }) { return ( -
+
{legend && {legend}} {children}
diff --git a/src/hooks/useTimers.ts b/src/hooks/useTimers.ts index 6297871..0449a25 100644 --- a/src/hooks/useTimers.ts +++ b/src/hooks/useTimers.ts @@ -116,7 +116,7 @@ export const useTimerApiActions = () => { await setData((prev) => ({ ...prev, [timer.id]: { ...timer, elapsedTime: 0, activeSession: undefined, sessions: [] } })); }, - deleteTimer: async (timer: Timer) => { + deleteTimer: async (timer: Pick) => { await setData((prev) => { const next = { ...prev }; delete next[timer.id]; diff --git a/src/lang/de.json b/src/lang/de.json index f2d15d5..7b4aa2d 100644 --- a/src/lang/de.json +++ b/src/lang/de.json @@ -6,6 +6,20 @@ "timers.list.no-options": "Keine Timer", "timers.list.unknown-project-group": "(unbekanntes Projekt)", + "timers.overview.greeting.period.morning": "Guten Morgen", + "timers.overview.greeting.period.afternoon": "Guten Tag", + "timers.overview.greeting.period.evening": "Guten Abend", + "timers.overview.greeting.period.night": "Gute Nacht", + "timers.overview.submit-all": "Alle übertragen", + "timers.overview.tracked": "Erfasst", + "timers.overview.timers": "Nicht übertragen", + "timers.modal.submit-timers.title": "Alle Timer übertragen", + "timers.modal.submit-timers.description": "Alle erfassten Timer als Zeitaufwände übertragen", + "timers.modal.submit-timers.invalid.unknown-issue": "Unbekanntes Ticket", + "timers.modal.submit-timers.invalid.no-permission": "Keine Berechtigung", + "timers.modal.submit-timers.validation.at-least-one-required": "Mindestens ein Timer muss ausgewählt sein", + "timers.modal.submit-timers.submit": "Alle übertragen ({count})", + "timers.modal.submit-timers.success": "{count, plural, one {# Zeitaufwand} other {# Zeitaufwände}} erfolgreich erstellt", "timer.context-menu.start": "Timer starten", "timer.context-menu.pause": "Timer pausieren", "timer.context-menu.edit": "Timer bearbeiten", @@ -181,6 +195,8 @@ "settings.features.persistent-comments.description": "Kommentare zwischenspeichern, bis sie übermittelt wurden", "settings.features.show-current-issue-timer.title": "Aktuellen Ticket-Timer anzeigen", "settings.features.show-current-issue-timer.description": "Aktuellen Ticket-Timer auf Redmine Seite anzeigen", + "settings.features.timers-overview.title": "Timer-Übersicht", + "settings.features.timers-overview.description": "Zusammenfassung der heute erfassten Zeit", "settings.style": "Styling", "settings.style.display-search-always.title": "Suche immer anzeigen", "settings.style.sticky-scroll.title": "Sticky scroll", diff --git a/src/lang/en.json b/src/lang/en.json index 3685a00..42a3218 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -6,6 +6,20 @@ "timers.list.no-options": "No timers", "timers.list.unknown-project-group": "(unknown project)", + "timers.overview.greeting.period.morning": "Good morning", + "timers.overview.greeting.period.afternoon": "Good afternoon", + "timers.overview.greeting.period.evening": "Good evening", + "timers.overview.greeting.period.night": "Good night", + "timers.overview.submit-all": "Submit all", + "timers.overview.tracked": "Tracked", + "timers.overview.timers": "Unsubmitted", + "timers.modal.submit-timers.title": "Submit all timers", + "timers.modal.submit-timers.description": "Submit all timers as time entries", + "timers.modal.submit-timers.invalid.unknown-issue": "Unknown issue", + "timers.modal.submit-timers.invalid.no-permission": "No permission", + "timers.modal.submit-timers.validation.at-least-one-required": "At least one timer must be included", + "timers.modal.submit-timers.submit": "Submit all ({count})", + "timers.modal.submit-timers.success": "{count, plural, one {# time entry} other {# time entries}} successfully created", "timer.context-menu.start": "Start timer", "timer.context-menu.pause": "Pause timer", "timer.context-menu.edit": "Edit timer", @@ -181,6 +195,8 @@ "settings.features.persistent-comments.description": "Save comments until they are submitted", "settings.features.show-current-issue-timer.title": "Show current issue timer", "settings.features.show-current-issue-timer.description": "Show current issue timer on Redmine page", + "settings.features.timers-overview.title": "Timers overview", + "settings.features.timers-overview.description": "Show a summary of today's tracked time", "settings.style": "Styling", "settings.style.display-search-always.title": "Display search always", "settings.style.sticky-scroll.title": "Sticky scroll", diff --git a/src/lang/fr.json b/src/lang/fr.json index 207d2c0..10fe883 100644 --- a/src/lang/fr.json +++ b/src/lang/fr.json @@ -6,6 +6,20 @@ "timers.list.no-options": "No timers", "timers.list.unknown-project-group": "(unknown project)", + "timers.overview.greeting.period.morning": "Good morning", + "timers.overview.greeting.period.afternoon": "Good afternoon", + "timers.overview.greeting.period.evening": "Good evening", + "timers.overview.greeting.period.night": "Good night", + "timers.overview.submit-all": "Submit all", + "timers.overview.tracked": "Tracked", + "timers.overview.timers": "Unsubmitted", + "timers.modal.submit-timers.title": "Submit all timers", + "timers.modal.submit-timers.description": "Submit all timers as time entries", + "timers.modal.submit-timers.invalid.unknown-issue": "Unknown issue", + "timers.modal.submit-timers.invalid.no-permission": "No permission", + "timers.modal.submit-timers.validation.at-least-one-required": "At least one timer must be included", + "timers.modal.submit-timers.submit": "Submit all ({count})", + "timers.modal.submit-timers.success": "{count, plural, one {# time entry} other {# time entries}} successfully created", "timer.context-menu.start": "Démarrer le minuteur", "timer.context-menu.pause": "Mettre le minuteur en pause", "timer.context-menu.edit": "Modifier le minuteur", @@ -181,6 +195,8 @@ "settings.features.persistent-comments.description": "Mettre en cache les commentaires jusqu'à leur soumission", "settings.features.show-current-issue-timer.title": "Show current issue timer", "settings.features.show-current-issue-timer.description": "Show current issue timer on Redmine page", + "settings.features.timers-overview.title": "Timers overview", + "settings.features.timers-overview.description": "Show a summary of today's tracked time", "settings.style": "Style", "settings.style.display-search-always.title": "Afficher toujours la recherche", "settings.style.sticky-scroll.title": "Défilement fixe", diff --git a/src/lang/ru.json b/src/lang/ru.json index 71c3511..0182878 100644 --- a/src/lang/ru.json +++ b/src/lang/ru.json @@ -6,6 +6,20 @@ "timers.list.no-options": "No timers", "timers.list.unknown-project-group": "(unknown project)", + "timers.overview.greeting.period.morning": "Good morning", + "timers.overview.greeting.period.afternoon": "Good afternoon", + "timers.overview.greeting.period.evening": "Good evening", + "timers.overview.greeting.period.night": "Good night", + "timers.overview.submit-all": "Submit all", + "timers.overview.tracked": "Tracked", + "timers.overview.timers": "Unsubmitted", + "timers.modal.submit-timers.title": "Submit all timers", + "timers.modal.submit-timers.description": "Submit all timers as time entries", + "timers.modal.submit-timers.invalid.unknown-issue": "Unknown issue", + "timers.modal.submit-timers.invalid.no-permission": "No permission", + "timers.modal.submit-timers.validation.at-least-one-required": "At least one timer must be included", + "timers.modal.submit-timers.submit": "Submit all ({count})", + "timers.modal.submit-timers.success": "{count, plural, one {# time entry} other {# time entries}} successfully created", "timer.context-menu.start": "Запустить таймер", "timer.context-menu.pause": "Остановить таймер", "timer.context-menu.edit": "Изменить таймер", @@ -181,6 +195,8 @@ "settings.features.persistent-comments.description": "Кешировать комментарии пока они не отправлены", "settings.features.show-current-issue-timer.title": "Show current issue timer", "settings.features.show-current-issue-timer.description": "Show current issue timer on Redmine page", + "settings.features.timers-overview.title": "Timers overview", + "settings.features.timers-overview.description": "Show a summary of today's tracked time", "settings.style": "Отображение", "settings.style.display-search-always.title": "Display search always", "settings.style.sticky-scroll.title": "Закрепить названия проектов при прокрутке списка задач", diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 0788669..bf85b26 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -5,3 +5,11 @@ export function cn(...inputs: ClassValue[]) { // eslint-disable-next-line tailwindcss/no-custom-classname return twMerge(clsx(inputs)); } + +/** + * Filter function to omit undefined values from the list. + * Use `filter(omitUndefinedFilter)` to filter out undefined values from an array. + */ +export function omitUndefinedFilter(value: T | undefined): value is T { + return value !== undefined; +} diff --git a/src/provider/SettingsProvider.tsx b/src/provider/SettingsProvider.tsx index fbce57f..ff9d6e0 100644 --- a/src/provider/SettingsProvider.tsx +++ b/src/provider/SettingsProvider.tsx @@ -33,6 +33,7 @@ export const settingsSchema = ({ formatMessage }: { formatMessage?: ReturnType )} /> + ( + + )} + /> diff --git a/src/routes/timers.tsx b/src/routes/timers.tsx index 52c1c02..a201323 100644 --- a/src/routes/timers.tsx +++ b/src/routes/timers.tsx @@ -2,16 +2,17 @@ import { useSuspenseRedmineIssues } from "@/api/redmine/hooks/useRedmineIssues"; import { OptionalSidebarScrollspy } from "@/components/general/SidebarScrollspy"; import { ProjectTimersGroup, ProjectTimersGroupSkeleton } from "@/components/timer/ProjectTimersGroup"; import TimerSearch, { useTimerSearch } from "@/components/timer/TimerSearch"; +import TimersOverview, { TimersOverviewSkeleton } from "@/components/timer/TimersOverview"; import { Skeleton } from "@/components/ui/skeleton"; import PermissionProvider from "@/provider/PermissionsProvider"; import { useSettings } from "@/provider/SettingsProvider"; import { groupTimers } from "@/utils/groupTimers"; import { randomInt } from "@/utils/random"; +import { useMediaQuery } from "@mantine/hooks"; import { createFileRoute } from "@tanstack/react-router"; import { SquareChartGanttIcon } from "lucide-react"; import { useDeferredValue, useMemo } from "react"; import { FormattedMessage } from "react-intl"; -import { useMediaQuery } from "@mantine/hooks"; import TimersBadge from "../components/timer/TimersBadge"; import useTimers from "../hooks/useTimers"; @@ -50,6 +51,8 @@ const TimersPage = () => { + {settings.features.timersOverview && } + { return ( <> + {settings.features.timersOverview && } +