Compare commits
No commits in common. "fdf57fa6b67db72bb92d5d167d0b36962c30707a" and "d4e2062243ea76975ae74615ef1206ed01b4f842" have entirely different histories.
fdf57fa6b6
...
d4e2062243
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,7 +11,6 @@ yarn-error.log
|
||||||
|
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
.docker
|
|
||||||
/.cache
|
/.cache
|
||||||
/build
|
/build
|
||||||
/public/build
|
/public/build
|
||||||
|
|
|
||||||
44
README.md
44
README.md
|
|
@ -1,13 +1,11 @@
|
||||||
# WorkTimer
|
# WorkTimer
|
||||||
|
|
||||||
<img src="https://github.com/nzambello/work-timer/raw/main/public/logo.png" width="100" height="100" alt="WorkTimer" />
|
<img src="./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)
|
||||||
|
|
@ -17,7 +15,6 @@ View on [DockerHub](https://hub.docker.com/r/nzambello/work-timer).
|
||||||
- [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)
|
||||||
|
|
||||||
|
|
@ -48,26 +45,10 @@ 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
|
||||||
work-timer:
|
uptime-kuma:
|
||||||
depends_on:
|
depends_on:
|
||||||
- watchtower
|
- watchtower
|
||||||
image: nzambello/work-timer
|
image: nzambello/work-timer
|
||||||
|
|
@ -131,35 +112,28 @@ 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="https://github.com/nzambello/work-timer/raw/main/public/images/00-time-entries-light.png" />
|
<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/01-time-entries-dark.png" />
|
<img width="300" src="./public/images/01-time-entries-dark.png" />
|
||||||
|
|
||||||
### Time tracking
|
### Time tracking
|
||||||
|
|
||||||
<img width="500" src="https://github.com/nzambello/work-timer/raw/main/public/images/02-new-time-entry.png" />
|
<img width="500" src="./public/images/02-new-time-entry.png" />
|
||||||
|
|
||||||
### Projects
|
### Projects
|
||||||
|
|
||||||
<img width="300" src="https://github.com/nzambello/work-timer/raw/main/public/images/03-projects.png" />
|
<img width="300" src="./public/images/03-projects.png" />
|
||||||
<img width="300" src="https://github.com/nzambello/work-timer/raw/main/public/images/04-new-project.png" />
|
<img width="300" src="./public/images/04-new-project.png" />
|
||||||
|
|
||||||
### Reports
|
### Reports
|
||||||
|
|
||||||
<img width="500" src="https://github.com/nzambello/work-timer/raw/main/public/images/05-reports.png" />
|
<img width="500" src="./public/images/05-reports.png" />
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[Nicola Zambello](https://github.com/nzambello) © 2023
|
[Nicola Zambello](https://github.com/nzambello) © 2023
|
||||||
|
|
||||||
[GNU GPLv3](https://github.com/nzambello/work-timer/raw/main/LICENSE)
|
[GNU GPLv3](./LICENSE)
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,6 @@ 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);
|
||||||
|
|
@ -67,7 +62,6 @@ const TimeElapsed = ({ startTime, endTime }: Props) => {
|
||||||
>
|
>
|
||||||
<code>{hoursString}</code>
|
<code>{hoursString}</code>
|
||||||
</pre>
|
</pre>
|
||||||
{isClient && (
|
|
||||||
<MediaQuery
|
<MediaQuery
|
||||||
smallerThan="sm"
|
smallerThan="sm"
|
||||||
styles={{
|
styles={{
|
||||||
|
|
@ -97,7 +91,6 @@ const TimeElapsed = ({ startTime, endTime }: Props) => {
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</MediaQuery>
|
</MediaQuery>
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,11 @@ 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="work-timer-export-${timestamp}.csv"`,
|
'Content-Disposition': 'attachment; filename="export.csv"'
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -159,17 +159,6 @@ 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}
|
||||||
|
|
@ -192,7 +181,7 @@ export default function Index() {
|
||||||
className={classes.control}
|
className={classes.control}
|
||||||
leftIcon={<GitHub />}
|
leftIcon={<GitHub />}
|
||||||
>
|
>
|
||||||
View on GitHub
|
GitHub
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Container>
|
</Container>
|
||||||
|
|
|
||||||
|
|
@ -48,21 +48,13 @@ export async function loader({ request }: LoaderArgs) {
|
||||||
|
|
||||||
await updateDuration(user.id);
|
await updateDuration(user.id);
|
||||||
|
|
||||||
const timeByProject = await getTimeEntriesByDateAndProject({
|
return json({
|
||||||
|
user,
|
||||||
|
timeByProject: await getTimeEntriesByDateAndProject({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo
|
dateTo
|
||||||
});
|
}),
|
||||||
|
|
||||||
const total = timeByProject.reduce(
|
|
||||||
(acc, curr) => acc + (curr._sum.duration || 0),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
|
|
||||||
return json({
|
|
||||||
user,
|
|
||||||
timeByProject,
|
|
||||||
total,
|
|
||||||
projects: await getProjects({ userId: user.id })
|
projects: await getProjects({ userId: user.id })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -236,8 +228,8 @@ export default function ReportPage() {
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Project</th>
|
<th>Project</th>
|
||||||
<th scope="col">Time</th>
|
<th>Time</th>
|
||||||
{hourlyRate && <th scope="col">Billing</th>}
|
{hourlyRate && <th>Billing</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
@ -248,7 +240,7 @@ export default function ReportPage() {
|
||||||
}[]
|
}[]
|
||||||
).map((projectData) => (
|
).map((projectData) => (
|
||||||
<tr key={projectData.projectId}>
|
<tr key={projectData.projectId}>
|
||||||
<td scope="row">
|
<td>
|
||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
<ColorSwatch
|
<ColorSwatch
|
||||||
mr="sm"
|
mr="sm"
|
||||||
|
|
@ -280,25 +272,6 @@ 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