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
|
*.swp
|
||||||
|
|
||||||
|
.docker
|
||||||
/.cache
|
/.cache
|
||||||
/build
|
/build
|
||||||
/public/build
|
/public/build
|
||||||
|
|
|
||||||
44
README.md
44
README.md
|
|
@ -1,11 +1,13 @@
|
||||||
# WorkTimer
|
# 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.
|
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.
|
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
|
## Table of contents
|
||||||
|
|
||||||
- [Features](#features)
|
- [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)
|
- [Tech stack](#tech-stack)
|
||||||
- [Running locally](#running-locally)
|
- [Running locally](#running-locally)
|
||||||
- [Running with Docker](#running-with-docker)
|
- [Running with Docker](#running-with-docker)
|
||||||
|
- [Multi-platform docker image](#multi-platform-docker-image)
|
||||||
- [Screenshots](#screenshots)
|
- [Screenshots](#screenshots)
|
||||||
- [License](#license)
|
- [License](#license)
|
||||||
|
|
||||||
|
|
@ -45,10 +48,26 @@ If you want to use different defaults, you can build your own image. See [Runnin
|
||||||
|
|
||||||
### Docker compose
|
### 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:
|
Example of docker-compose.yml with [Traefik](https://traefik.io/) as reverse proxy:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
uptime-kuma:
|
work-timer:
|
||||||
depends_on:
|
depends_on:
|
||||||
- watchtower
|
- watchtower
|
||||||
image: nzambello/work-timer
|
image: nzambello/work-timer
|
||||||
|
|
@ -112,28 +131,35 @@ docker built -t work-timer .
|
||||||
docker run -p 127.0.0.1:8080:8080 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
|
## Screenshots
|
||||||
|
|
||||||
### Light / Dark mode
|
### Light / Dark mode
|
||||||
|
|
||||||
<img width="300" src="./public/images/00-time-entries-light.png" />
|
<img width="300" src="https://github.com/nzambello/work-timer/raw/main/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/01-time-entries-dark.png" />
|
||||||
|
|
||||||
### Time tracking
|
### 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
|
### Projects
|
||||||
|
|
||||||
<img width="300" src="./public/images/03-projects.png" />
|
<img width="300" src="https://github.com/nzambello/work-timer/raw/main/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/04-new-project.png" />
|
||||||
|
|
||||||
### Reports
|
### 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
|
## License
|
||||||
|
|
||||||
[Nicola Zambello](https://github.com/nzambello) © 2023
|
[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);
|
return () => clearInterval(interval);
|
||||||
}, [startTime, endTime]);
|
}, [startTime, endTime]);
|
||||||
|
|
||||||
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setIsClient(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const hours = Math.floor(elapsed / 60 / 60);
|
const hours = Math.floor(elapsed / 60 / 60);
|
||||||
const minutes = Math.floor((elapsed - hours * 60 * 60) / 60);
|
const minutes = Math.floor((elapsed - hours * 60 * 60) / 60);
|
||||||
const seconds = Math.floor(elapsed - hours * 60 * 60 - minutes * 60);
|
const seconds = Math.floor(elapsed - hours * 60 * 60 - minutes * 60);
|
||||||
|
|
@ -62,35 +67,37 @@ const TimeElapsed = ({ startTime, endTime }: Props) => {
|
||||||
>
|
>
|
||||||
<code>{hoursString}</code>
|
<code>{hoursString}</code>
|
||||||
</pre>
|
</pre>
|
||||||
<MediaQuery
|
{isClient && (
|
||||||
smallerThan="sm"
|
<MediaQuery
|
||||||
styles={{
|
smallerThan="sm"
|
||||||
display: 'none'
|
styles={{
|
||||||
}}
|
display: 'none'
|
||||||
>
|
|
||||||
<p
|
|
||||||
style={{
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
margin: 0
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<p
|
||||||
{Intl.DateTimeFormat(user?.dateFormat || 'en-GB', {
|
style={{
|
||||||
hour: '2-digit',
|
fontSize: '0.75rem',
|
||||||
minute: '2-digit'
|
margin: 0
|
||||||
}).format(new Date(startTime))}
|
}}
|
||||||
</span>
|
>
|
||||||
<span dangerouslySetInnerHTML={{ __html: ' — ' }} />
|
<span>
|
||||||
<span>
|
{Intl.DateTimeFormat(user?.dateFormat || 'en-GB', {
|
||||||
{endTime
|
hour: '2-digit',
|
||||||
? Intl.DateTimeFormat(user?.dateFormat || 'en-GB', {
|
minute: '2-digit'
|
||||||
hour: '2-digit',
|
}).format(new Date(startTime))}
|
||||||
minute: '2-digit'
|
</span>
|
||||||
}).format(new Date(endTime))
|
<span dangerouslySetInnerHTML={{ __html: ' — ' }} />
|
||||||
: 'now'}
|
<span>
|
||||||
</span>
|
{endTime
|
||||||
</p>
|
? Intl.DateTimeFormat(user?.dateFormat || 'en-GB', {
|
||||||
</MediaQuery>
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
}).format(new Date(endTime))
|
||||||
|
: 'now'}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</MediaQuery>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,13 @@ export async function loader({ request }: LoaderArgs) {
|
||||||
header: true
|
header: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, -3);
|
||||||
|
|
||||||
return new Response(csv, {
|
return new Response(csv, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'text/csv',
|
'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
|
management, and monthly or custom reports
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<h2>
|
||||||
|
<Text
|
||||||
|
component="span"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: 'blue', to: 'cyan' }}
|
||||||
|
inherit
|
||||||
|
>
|
||||||
|
Your time, your data
|
||||||
|
</Text>
|
||||||
|
</h2>
|
||||||
|
|
||||||
<Group className={classes.controls}>
|
<Group className={classes.controls}>
|
||||||
<Button
|
<Button
|
||||||
component={Link}
|
component={Link}
|
||||||
|
|
@ -181,7 +192,7 @@ export default function Index() {
|
||||||
className={classes.control}
|
className={classes.control}
|
||||||
leftIcon={<GitHub />}
|
leftIcon={<GitHub />}
|
||||||
>
|
>
|
||||||
GitHub
|
View on GitHub
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
|
||||||
|
|
@ -48,13 +48,21 @@ export async function loader({ request }: LoaderArgs) {
|
||||||
|
|
||||||
await updateDuration(user.id);
|
await updateDuration(user.id);
|
||||||
|
|
||||||
|
const timeByProject = await getTimeEntriesByDateAndProject({
|
||||||
|
userId: user.id,
|
||||||
|
dateFrom,
|
||||||
|
dateTo
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = timeByProject.reduce(
|
||||||
|
(acc, curr) => acc + (curr._sum.duration || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
user,
|
user,
|
||||||
timeByProject: await getTimeEntriesByDateAndProject({
|
timeByProject,
|
||||||
userId: user.id,
|
total,
|
||||||
dateFrom,
|
|
||||||
dateTo
|
|
||||||
}),
|
|
||||||
projects: await getProjects({ userId: user.id })
|
projects: await getProjects({ userId: user.id })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -228,8 +236,8 @@ export default function ReportPage() {
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Project</th>
|
<th>Project</th>
|
||||||
<th>Time</th>
|
<th scope="col">Time</th>
|
||||||
{hourlyRate && <th>Billing</th>}
|
{hourlyRate && <th scope="col">Billing</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -240,7 +248,7 @@ export default function ReportPage() {
|
||||||
}[]
|
}[]
|
||||||
).map((projectData) => (
|
).map((projectData) => (
|
||||||
<tr key={projectData.projectId}>
|
<tr key={projectData.projectId}>
|
||||||
<td>
|
<td scope="row">
|
||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
<ColorSwatch
|
<ColorSwatch
|
||||||
mr="sm"
|
mr="sm"
|
||||||
|
|
@ -272,6 +280,25 @@ export default function ReportPage() {
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</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>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue