Compare commits

...

10 commits

6 changed files with 121 additions and 47 deletions

1
.gitignore vendored
View file

@ -11,6 +11,7 @@ yarn-error.log
*.swp
.docker
/.cache
/build
/public/build

View file

@ -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)

View file

@ -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>
);
};

View file

@ -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"`,
},
});
}

View file

@ -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>

View file

@ -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>
)}
</>