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 *.swp
.docker
/.cache /.cache
/build /build
/public/build /public/build

View file

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

View file

@ -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: '&nbsp;&mdash;&nbsp;' }} /> <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: '&nbsp;&mdash;&nbsp;' }} />
: '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>
); );
}; };

View file

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

View file

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

View file

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