Compare commits
10 commits
d4e2062243
...
fdf57fa6b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdf57fa6b6 | ||
|
|
760e56fcc3 | ||
|
|
9630e079e8 | ||
|
|
64d3e0160d | ||
|
|
7288ea834e | ||
|
|
e8cd3cae7e | ||
|
|
baa39e81f0 | ||
|
|
ab5e977b96 | ||
|
|
4d146f84ad | ||
|
|
ba5628f3c8 |
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,6 +11,7 @@ yarn-error.log
|
|||
|
||||
*.swp
|
||||
|
||||
.docker
|
||||
/.cache
|
||||
/build
|
||||
/public/build
|
||||
|
|
|
|||
44
README.md
44
README.md
|
|
@ -1,11 +1,13 @@
|
|||
# WorkTimer
|
||||
|
||||
<img src="./public/logo.png" width="100" height="100" alt="WorkTimer" />
|
||||
<img src="https://github.com/nzambello/work-timer/raw/main/public/logo.png" width="100" height="100" alt="WorkTimer" />
|
||||
|
||||
Time tracking app built with Remix, supports authentication, projects management, and monthly or custom reports.
|
||||
|
||||
Built for self-hosting: host it anywhere you want, and use it for free. Your time, your data.
|
||||
|
||||
View on [DockerHub](https://hub.docker.com/r/nzambello/work-timer).
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Features](#features)
|
||||
|
|
@ -15,6 +17,7 @@ Built for self-hosting: host it anywhere you want, and use it for free. Your tim
|
|||
- [Tech stack](#tech-stack)
|
||||
- [Running locally](#running-locally)
|
||||
- [Running with Docker](#running-with-docker)
|
||||
- [Multi-platform docker image](#multi-platform-docker-image)
|
||||
- [Screenshots](#screenshots)
|
||||
- [License](#license)
|
||||
|
||||
|
|
@ -45,10 +48,26 @@ If you want to use different defaults, you can build your own image. See [Runnin
|
|||
|
||||
### Docker compose
|
||||
|
||||
Basic example:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
work-timer:
|
||||
image: nzambello/work-timer
|
||||
container_name: work-timer
|
||||
restart: always
|
||||
ports:
|
||||
- 8080:8080
|
||||
volumes:
|
||||
- ./dockerData/work-timer:/data # Path to data for DB persistence
|
||||
```
|
||||
|
||||
Example of docker-compose.yml with [Traefik](https://traefik.io/) as reverse proxy:
|
||||
|
||||
```yaml
|
||||
uptime-kuma:
|
||||
work-timer:
|
||||
depends_on:
|
||||
- watchtower
|
||||
image: nzambello/work-timer
|
||||
|
|
@ -112,28 +131,35 @@ docker built -t work-timer .
|
|||
docker run -p 127.0.0.1:8080:8080 work-timer
|
||||
```
|
||||
|
||||
### Multi-platform Docker image
|
||||
|
||||
```bash
|
||||
docker buildx create --name mybuilder --driver docker-container --bootstrap --use # create a new builder and switch to it using a single command.
|
||||
docker buildx build --platform linux/amd64,linux/arm64 -t nzambello/work-timer:latest --push .
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Light / Dark mode
|
||||
|
||||
<img width="300" src="./public/images/00-time-entries-light.png" />
|
||||
<img width="300" src="./public/images/01-time-entries-dark.png" />
|
||||
<img width="300" src="https://github.com/nzambello/work-timer/raw/main/public/images/00-time-entries-light.png" />
|
||||
<img width="300" src="https://github.com/nzambello/work-timer/raw/main/public/images/01-time-entries-dark.png" />
|
||||
|
||||
### Time tracking
|
||||
|
||||
<img width="500" src="./public/images/02-new-time-entry.png" />
|
||||
<img width="500" src="https://github.com/nzambello/work-timer/raw/main/public/images/02-new-time-entry.png" />
|
||||
|
||||
### Projects
|
||||
|
||||
<img width="300" src="./public/images/03-projects.png" />
|
||||
<img width="300" src="./public/images/04-new-project.png" />
|
||||
<img width="300" src="https://github.com/nzambello/work-timer/raw/main/public/images/03-projects.png" />
|
||||
<img width="300" src="https://github.com/nzambello/work-timer/raw/main/public/images/04-new-project.png" />
|
||||
|
||||
### Reports
|
||||
|
||||
<img width="500" src="./public/images/05-reports.png" />
|
||||
<img width="500" src="https://github.com/nzambello/work-timer/raw/main/public/images/05-reports.png" />
|
||||
|
||||
## License
|
||||
|
||||
[Nicola Zambello](https://github.com/nzambello) © 2023
|
||||
|
||||
[GNU GPLv3](./LICENSE)
|
||||
[GNU GPLv3](https://github.com/nzambello/work-timer/raw/main/LICENSE)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ const TimeElapsed = ({ startTime, endTime }: Props) => {
|
|||
return () => clearInterval(interval);
|
||||
}, [startTime, endTime]);
|
||||
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
const hours = Math.floor(elapsed / 60 / 60);
|
||||
const minutes = Math.floor((elapsed - hours * 60 * 60) / 60);
|
||||
const seconds = Math.floor(elapsed - hours * 60 * 60 - minutes * 60);
|
||||
|
|
@ -62,6 +67,7 @@ const TimeElapsed = ({ startTime, endTime }: Props) => {
|
|||
>
|
||||
<code>{hoursString}</code>
|
||||
</pre>
|
||||
{isClient && (
|
||||
<MediaQuery
|
||||
smallerThan="sm"
|
||||
styles={{
|
||||
|
|
@ -91,6 +97,7 @@ const TimeElapsed = ({ startTime, endTime }: Props) => {
|
|||
</span>
|
||||
</p>
|
||||
</MediaQuery>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ export async function loader({ request }: LoaderArgs) {
|
|||
header: true
|
||||
});
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, -3);
|
||||
|
||||
return new Response(csv, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv',
|
||||
'Content-Disposition': 'attachment; filename="export.csv"'
|
||||
}
|
||||
'Content-Disposition': `attachment; filename="work-timer-export-${timestamp}.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,6 +159,17 @@ export default function Index() {
|
|||
management, and monthly or custom reports
|
||||
</Text>
|
||||
|
||||
<h2>
|
||||
<Text
|
||||
component="span"
|
||||
variant="gradient"
|
||||
gradient={{ from: 'blue', to: 'cyan' }}
|
||||
inherit
|
||||
>
|
||||
Your time, your data
|
||||
</Text>
|
||||
</h2>
|
||||
|
||||
<Group className={classes.controls}>
|
||||
<Button
|
||||
component={Link}
|
||||
|
|
@ -181,7 +192,7 @@ export default function Index() {
|
|||
className={classes.control}
|
||||
leftIcon={<GitHub />}
|
||||
>
|
||||
GitHub
|
||||
View on GitHub
|
||||
</Button>
|
||||
</Group>
|
||||
</Container>
|
||||
|
|
|
|||
|
|
@ -48,13 +48,21 @@ export async function loader({ request }: LoaderArgs) {
|
|||
|
||||
await updateDuration(user.id);
|
||||
|
||||
return json({
|
||||
user,
|
||||
timeByProject: await getTimeEntriesByDateAndProject({
|
||||
const timeByProject = await getTimeEntriesByDateAndProject({
|
||||
userId: user.id,
|
||||
dateFrom,
|
||||
dateTo
|
||||
}),
|
||||
});
|
||||
|
||||
const total = timeByProject.reduce(
|
||||
(acc, curr) => acc + (curr._sum.duration || 0),
|
||||
0
|
||||
);
|
||||
|
||||
return json({
|
||||
user,
|
||||
timeByProject,
|
||||
total,
|
||||
projects: await getProjects({ userId: user.id })
|
||||
});
|
||||
}
|
||||
|
|
@ -228,8 +236,8 @@ export default function ReportPage() {
|
|||
<thead>
|
||||
<tr>
|
||||
<th>Project</th>
|
||||
<th>Time</th>
|
||||
{hourlyRate && <th>Billing</th>}
|
||||
<th scope="col">Time</th>
|
||||
{hourlyRate && <th scope="col">Billing</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -240,7 +248,7 @@ export default function ReportPage() {
|
|||
}[]
|
||||
).map((projectData) => (
|
||||
<tr key={projectData.projectId}>
|
||||
<td>
|
||||
<td scope="row">
|
||||
<Flex align="center">
|
||||
<ColorSwatch
|
||||
mr="sm"
|
||||
|
|
@ -272,6 +280,25 @@ export default function ReportPage() {
|
|||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
{!!reports.data?.timeByProject?.length && (
|
||||
<tfoot>
|
||||
<tr>
|
||||
<th scope="row">Totals</th>
|
||||
<th>{(reports.data.total / 1000 / 60 / 60).toFixed(2)} h</th>
|
||||
{hourlyRate && (
|
||||
<th>
|
||||
{(
|
||||
(reports.data.total * hourlyRate) /
|
||||
1000 /
|
||||
60 /
|
||||
60
|
||||
).toFixed(2)}{' '}
|
||||
{reports.data?.user?.currency ?? '€'}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
Loading…
Reference in a new issue