Skip to content

Commit e33b665

Browse files
committed
feat(time-table): Allow locking tables for read-only mode
1 parent cf5a46e commit e33b665

6 files changed

Lines changed: 438 additions & 291 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { createSlice } from "lib/yaasl"
2+
import { dateHelpers } from "utils/date-helpers"
3+
4+
const defaultValue: Record<string, true> = {
5+
[dateHelpers.today()]: true,
6+
}
7+
8+
export const editableDatesData = createSlice({
9+
name: "locaked-dates",
10+
defaultValue,
11+
reducers: {
12+
toggle: (state, date: string) => {
13+
const { [date]: existing, ...rest } = state
14+
return existing ? rest : { ...rest, [date]: true }
15+
},
16+
},
17+
})

src/data/editable-dates/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./editable-dates"
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import { Dispatch } from "react"
2+
3+
import { Trash } from "lucide-react"
4+
5+
import { Checkbox } from "components/ui/checkbox"
6+
import { showDialog } from "components/ui/dialog"
7+
import { IconButton } from "components/ui/icon-button"
8+
import { createColumnHelper, Table } from "components/ui/table"
9+
import { getDateAtom, useDateEntries, type TimeEntry } from "data/time-entries"
10+
11+
import { Duration } from "./duration"
12+
import { inputs } from "./inputs"
13+
14+
export interface CheckedProps {
15+
checked: Record<string, boolean>
16+
onCheckedChange: Dispatch<TimeEntry>
17+
}
18+
19+
interface TimeTableRowsProps
20+
extends CheckedProps,
21+
ReturnType<typeof useDateEntries> {
22+
date: string
23+
}
24+
25+
interface TableConfig {
26+
rowData: TimeEntry
27+
rowMeta: CheckedProps & {
28+
onChange: Dispatch<TimeEntry>
29+
onRemove: Dispatch<number>
30+
}
31+
}
32+
33+
const helper = createColumnHelper<TableConfig>()
34+
const checkedColumn = helper.column({
35+
name: "Selected",
36+
render: ({ rowData, checked, onCheckedChange }) => (
37+
<Checkbox
38+
checked={checked[rowData.id] ?? false}
39+
onCheckedChange={() => onCheckedChange(rowData)}
40+
/>
41+
),
42+
})
43+
const descriptionColumn = helper.column({
44+
name: "Description",
45+
colSize: "col-[2_/_-1] @xl:col-[2_/_-1] @4xl:col-[span_1]",
46+
className: "flex",
47+
render: ({ rowData, onChange }) => (
48+
<inputs.Description
49+
entry={rowData}
50+
onChange={data => onChange({ ...rowData, ...data })}
51+
/>
52+
),
53+
})
54+
const projectColumn = helper.column({
55+
name: "Project",
56+
colSize: "col-[2_/_6] @xl:col-[2] @4xl:col-[span_1]",
57+
className: "@4xl:*:w-full",
58+
render: ({ rowData, onChange }) => (
59+
<inputs.Project
60+
entry={rowData}
61+
onChange={data => onChange({ ...rowData, ...data })}
62+
/>
63+
),
64+
})
65+
const dateColumn = helper.column({
66+
name: "Date",
67+
colSize:
68+
"col-[6_/_-1] justify-self-end @xl:justify-self-start @xl:col-[auto]",
69+
render: ({ rowData, onChange }) => (
70+
<inputs.Date
71+
entry={rowData}
72+
onChange={data => onChange({ ...rowData, ...data })}
73+
/>
74+
),
75+
})
76+
const timeStartColumn = helper.column({
77+
name: "Time Start",
78+
colSize: "col-[2] @xl:col-[auto]",
79+
render: ({ rowData, onChange }) => (
80+
<inputs.TimeStart
81+
entry={rowData}
82+
onChange={data => onChange({ ...rowData, ...data })}
83+
/>
84+
),
85+
})
86+
const timeSeparatorColumn = helper.decorator({
87+
name: "",
88+
render: inputs.TimeSeparator,
89+
})
90+
const timeEndColumn = helper.column({
91+
name: "Time End",
92+
render: ({ rowData, onChange }) => (
93+
<inputs.TimeEnd
94+
entry={rowData}
95+
onChange={data => onChange({ ...rowData, ...data })}
96+
/>
97+
),
98+
})
99+
const durationColumn = helper.column({
100+
name: "Duration",
101+
render: ({ rowData }) => (
102+
<Duration entries={[rowData]} className="inline-block w-15 text-center" />
103+
),
104+
})
105+
const actionColumn = helper.column({
106+
name: "Actions",
107+
colSize: "col-[7] @xl:col-[auto]",
108+
render: ({ rowData, onRemove }) => (
109+
<IconButton
110+
title="Delete"
111+
hideTitle
112+
icon={Trash}
113+
onClick={() => onRemove(rowData.id)}
114+
className="@4xl:[[role='row']:not(:hover,:focus-within)_&]:opacity-0"
115+
/>
116+
),
117+
})
118+
119+
export const TimeTableEditable = ({
120+
date,
121+
atom,
122+
entries,
123+
checked,
124+
onCheckedChange,
125+
}: TimeTableRowsProps) => {
126+
const handleChange = (data: TimeEntry) => {
127+
if (data.date === date) {
128+
atom.actions.edit(data.id, data)
129+
return
130+
}
131+
// If date changed, move entry to new table
132+
atom.actions.delete(data.id)
133+
const newAtom = getDateAtom(data.date)
134+
const add = () => newAtom.actions.add(data)
135+
if (newAtom.didInit instanceof Promise) {
136+
void newAtom.didInit.then(add)
137+
} else {
138+
add()
139+
}
140+
}
141+
142+
const handleRemove = (id: number) =>
143+
showDialog({
144+
title: "Delete time entry?",
145+
description:
146+
"Do you really want to delete this time entry? This action cannot be reverted.",
147+
confirm: {
148+
caption: "Delete",
149+
look: "destructive",
150+
onClick: () => atom.actions.delete(id),
151+
},
152+
})
153+
154+
return (
155+
<Table<TableConfig>
156+
hideHeaders
157+
name="time-table"
158+
gridCols="grid-cols-[2.5rem_auto_auto_auto_auto_1fr_2.5rem] @xl:grid-cols-[2.5rem_1fr_auto_auto_auto_auto_auto_2.5rem] @4xl:grid-cols-[2.5rem_1fr_auto_auto_auto_auto_auto_auto_2.5rem]"
159+
rowData={entries}
160+
columns={[
161+
checkedColumn,
162+
descriptionColumn,
163+
projectColumn,
164+
dateColumn,
165+
timeStartColumn,
166+
timeSeparatorColumn,
167+
timeEndColumn,
168+
durationColumn,
169+
actionColumn,
170+
]}
171+
rowMeta={{
172+
checked,
173+
onCheckedChange,
174+
onChange: handleChange,
175+
onRemove: handleRemove,
176+
}}
177+
/>
178+
)
179+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { useState } from "react"
2+
3+
import { Lock, Unlock } from "lucide-react"
4+
5+
import { IconButton } from "components/ui/icon-button"
6+
import { Tooltip } from "components/ui/tooltip"
7+
import { editableDatesData } from "data/editable-dates"
8+
import { projectsData } from "data/projects"
9+
import { type TimeEntry } from "data/time-entries"
10+
import { ProjectName } from "features/components/project-name"
11+
import { useIntersectionObserver } from "hooks/use-intersection-observer"
12+
import { useAtomValue } from "lib/yaasl"
13+
import { cn } from "utils/cn"
14+
import { getLocale } from "utils/get-locale"
15+
import { hstack, vstack } from "utils/styles"
16+
import { timeHelpers } from "utils/time-helpers"
17+
18+
import { Duration } from "./duration"
19+
20+
const formatDate = (date: string) => {
21+
const locale = getLocale()
22+
if (locale === "iso") {
23+
const weekday = new Date(date).toLocaleDateString("en", {
24+
weekday: "short",
25+
})
26+
return `${weekday}, ${date}`
27+
}
28+
return new Date(date).toLocaleDateString(locale, {
29+
day: "2-digit",
30+
month: "short",
31+
weekday: "short",
32+
})
33+
}
34+
35+
const DateDurations = ({ entries }: { entries: TimeEntry[] }) => {
36+
const projects = useAtomValue(projectsData)
37+
38+
const totalTimeByProject = [{ id: undefined }, ...projects]
39+
.map(project => {
40+
const items = entries.filter(entry => entry.projectId === project.id)
41+
const minutes = items.reduce(
42+
(result, { start, end }) => result + timeHelpers.getDiff(start, end),
43+
0
44+
)
45+
return {
46+
projectId: project.id,
47+
minutes,
48+
duration: timeHelpers.fromMinutes(minutes),
49+
}
50+
})
51+
.filter(({ minutes }) => minutes > 0)
52+
.sort((a, b) => b.minutes - a.minutes)
53+
54+
const total = totalTimeByProject.reduce(
55+
(result, { minutes }) => result + minutes,
56+
0
57+
)
58+
const totalDuration = (
59+
<span className="px-4 text-base">
60+
<span className="text-text-gentle">Total: </span>
61+
<Duration minutes={total} />
62+
</span>
63+
)
64+
65+
return total === 0 ? (
66+
totalDuration
67+
) : (
68+
<Tooltip.Root>
69+
<Tooltip.Trigger asChild>
70+
<button className="rounded-md">{totalDuration}</button>
71+
</Tooltip.Trigger>
72+
<Tooltip.Content align="end" asChild>
73+
<div className={cn(vstack({ justify: "end" }), "text-sm")}>
74+
{totalTimeByProject.map(({ duration, projectId }) => (
75+
<span
76+
key={projectId}
77+
className={hstack({ justify: "between", gap: 2 })}
78+
>
79+
<ProjectName projectId={projectId} />
80+
<span className="font-mono">{duration}</span>
81+
</span>
82+
))}
83+
</div>
84+
</Tooltip.Content>
85+
</Tooltip.Root>
86+
)
87+
}
88+
89+
interface TimeTableHeaderProps {
90+
date: string
91+
entries: TimeEntry[]
92+
isEditable: boolean
93+
}
94+
export const TimeTableHeader = ({
95+
date,
96+
entries,
97+
isEditable,
98+
}: TimeTableHeaderProps) => {
99+
const [topOffset, setTopOffset] = useState("0px")
100+
const { ref, isIntersecting } = useIntersectionObserver({
101+
rootMargin: `-${topOffset} 0px 0px 0px`, // trigger offset to the top of the scroll area (window)
102+
})
103+
104+
return (
105+
<>
106+
<div ref={ref} />
107+
<div
108+
ref={element => {
109+
if (!element) return
110+
const styles = getComputedStyle(element)
111+
setTopOffset(styles.top)
112+
}}
113+
className={cn(
114+
hstack({ align: "center" }),
115+
"h-10 rounded-t-lg border-b border-stroke-gentle bg-background-page",
116+
"sticky top-18 z-20",
117+
!isIntersecting && "rounded-lg"
118+
)}
119+
>
120+
<h2 className="mr-2 pl-4 text-base">{formatDate(date)}</h2>
121+
<IconButton
122+
icon={isEditable ? Unlock : Lock}
123+
title="Toggle editability"
124+
iconColor="muted"
125+
size="sm"
126+
onClick={() => editableDatesData.actions.toggle(date)}
127+
/>
128+
<div className="flex-1" />
129+
<DateDurations entries={entries} />
130+
</div>
131+
</>
132+
)
133+
}

0 commit comments

Comments
 (0)