Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ba9b27019 | |||
| d7babf7230 | |||
| 224e12294a | |||
| 281b4db9fc | |||
| d7aa4e3534 | |||
| 36c4502595 | |||
| 71d7f5cb07 |
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.idea
|
||||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# install packages first
|
||||||
|
COPY package* .
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# copy everything else and run the build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM busybox:1.37 AS httpd
|
||||||
|
|
||||||
|
# add user to run web server as
|
||||||
|
RUN adduser -D static
|
||||||
|
USER static
|
||||||
|
WORKDIR /home/static
|
||||||
|
|
||||||
|
# copy build files from the node worker
|
||||||
|
COPY --from=build /build/dist /home/static
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
# run httpd in foreground, verbose, on port 3000
|
||||||
|
CMD ["busybox", "httpd", "-f", "-v", "-p", "3000"]
|
||||||
|
|
||||||
21
LICENSE.md
Normal file
21
LICENSE.md
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Sam Stevens
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
13
README.md
13
README.md
@@ -1,7 +1,11 @@
|
|||||||
# `Daily Time Recording`
|
# Daily Time Recording
|
||||||
|
|
||||||
Simple utility to calculate how much time you have left to record today.
|
Simple utility to calculate how much time you have left to record today.
|
||||||
|
|
||||||
|
The start time & duration are kept in browser storage. The recorded duration is also kept but cleared every day.
|
||||||
|
|
||||||
|
# Operation
|
||||||
|
|
||||||
Inputs:
|
Inputs:
|
||||||
|
|
||||||
- Start Time
|
- Start Time
|
||||||
@@ -9,6 +13,9 @@ Inputs:
|
|||||||
- Break Duration
|
- Break Duration
|
||||||
- Break Taken Yes/No
|
- Break Taken Yes/No
|
||||||
- Recorded Duration
|
- Recorded Duration
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
- Remaining Duration
|
- Remaining Duration
|
||||||
|
|
||||||
Calculation:
|
Calculation:
|
||||||
@@ -22,7 +29,3 @@ Remaining = Now - Start - Break (if taken) - Recorded
|
|||||||
- `npm run build` - Builds for production, emitting to `dist/`
|
- `npm run build` - Builds for production, emitting to `dist/`
|
||||||
|
|
||||||
- `npm run preview` - Starts a server at http://localhost:4173/ to test production build locally
|
- `npm run preview` - Starts a server at http://localhost:4173/ to test production build locally
|
||||||
|
|
||||||
# Todo
|
|
||||||
|
|
||||||
- Save start, target & break duration in browser storage
|
|
||||||
@@ -10,12 +10,13 @@ import './style.css';
|
|||||||
import {durationRe} from "./duration.js";
|
import {durationRe} from "./duration.js";
|
||||||
import Duration from "./Duration.jsx";
|
import Duration from "./Duration.jsx";
|
||||||
import calculateRemaining from "./calc.js";
|
import calculateRemaining from "./calc.js";
|
||||||
|
import {getStoredRecordedTime, getStoredDefaults, setStoredRecordedTime, setStoredDefaults} from "./store.js";
|
||||||
|
|
||||||
const schema = yup.object({
|
const schema = yup.object({
|
||||||
startTime: yup.string().matches(timeRe, {message: '12 or 24 hr time format required'}).required(),
|
startTime: yup.string().matches(timeRe, {message: '12 or 24 hr time format required'}).required(),
|
||||||
target: yup.string().matches(durationRe, {message: 'duration in hh:mm or hrs and mins required'}).required(),
|
target: yup.string().matches(durationRe, {message: 'duration in hh:mm or hrs and mins required'}).required(),
|
||||||
break: yup.string().matches(durationRe, {message: 'duration in hh:mm or hrs and mins required'}).required(),
|
break: yup.string().matches(durationRe, {message: 'duration in hh:mm or hrs and mins required'}).required(),
|
||||||
breakTaken: yup.bool().required(),
|
breakTaken: yup.boolean().required(),
|
||||||
recorded: yup.string().matches(durationRe, {message: 'duration in hh:mm or hrs and mins required'}).required()
|
recorded: yup.string().matches(durationRe, {message: 'duration in hh:mm or hrs and mins required'}).required()
|
||||||
}).required();
|
}).required();
|
||||||
|
|
||||||
@@ -30,7 +31,10 @@ export function App() {
|
|||||||
startTime: '09:00',
|
startTime: '09:00',
|
||||||
target: '7.5h',
|
target: '7.5h',
|
||||||
break: '1h',
|
break: '1h',
|
||||||
recorded: ''
|
breakTaken: false,
|
||||||
|
recorded: '0',
|
||||||
|
...getStoredRecordedTime(),
|
||||||
|
...getStoredDefaults()
|
||||||
},
|
},
|
||||||
resolver: yupResolver(schema)
|
resolver: yupResolver(schema)
|
||||||
});
|
});
|
||||||
@@ -49,6 +53,10 @@ export function App() {
|
|||||||
}
|
}
|
||||||
setRemaining(calculateRemaining(currentData));
|
setRemaining(calculateRemaining(currentData));
|
||||||
|
|
||||||
|
// save new values to storage
|
||||||
|
setStoredDefaults(currentData);
|
||||||
|
setStoredRecordedTime(currentData.recorded, currentData.breakTaken);
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
setRemaining(calculateRemaining(currentData));
|
setRemaining(calculateRemaining(currentData));
|
||||||
}, 60000);
|
}, 60000);
|
||||||
@@ -61,6 +69,13 @@ export function App() {
|
|||||||
return () => sub.unsubscribe();
|
return () => sub.unsubscribe();
|
||||||
}, [handleSubmit, watch]);
|
}, [handleSubmit, watch]);
|
||||||
|
|
||||||
|
// submit form when loaded if we have a previously recorded time
|
||||||
|
useEffect(() => {
|
||||||
|
if (getStoredRecordedTime()) {
|
||||||
|
handleSubmit(onSubmit)(null);
|
||||||
|
}
|
||||||
|
}, [handleSubmit]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="window" style={{margin: "32px auto", width: "250px"}}>
|
<div class="window" style={{margin: "32px auto", width: "250px"}}>
|
||||||
<div class="title-bar">
|
<div class="title-bar">
|
||||||
@@ -68,6 +83,10 @@ export function App() {
|
|||||||
Daily Time Tracking
|
Daily Time Tracking
|
||||||
</div>
|
</div>
|
||||||
<div class="title-bar-controls">
|
<div class="title-bar-controls">
|
||||||
|
<button type="button"
|
||||||
|
aria-label="Help"
|
||||||
|
title="Open in GitHub - https://github.com/sam159/daily-time-recording"
|
||||||
|
onClick={() => window.open("https://github.com/sam159/daily-time-recording", "_blank")} />
|
||||||
<button type="button" aria-label="Close" />
|
<button type="button" aria-label="Close" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
74
src/store.js
Normal file
74
src/store.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import * as yup from "yup";
|
||||||
|
|
||||||
|
const DEFAULTS_KEY = 'defaults';
|
||||||
|
const RECORDED_KEY = 'recorded';
|
||||||
|
|
||||||
|
const defaultsSchema = yup.object({
|
||||||
|
startTime: yup.string().required(),
|
||||||
|
target: yup.string().required(),
|
||||||
|
break: yup.string().required(),
|
||||||
|
}).required();
|
||||||
|
|
||||||
|
const recordedSchema = yup.object({
|
||||||
|
recorded: yup.string().required(),
|
||||||
|
breakTaken: yup.boolean().required(),
|
||||||
|
updated: yup.number().required().nullable()
|
||||||
|
}).required();
|
||||||
|
|
||||||
|
function getJsonValue(key, defaultValue = {}) {
|
||||||
|
const item = localStorage.getItem(key);
|
||||||
|
if (item != null) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(item);
|
||||||
|
if (typeof data === 'object') {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredDefaults() {
|
||||||
|
const value = getJsonValue(DEFAULTS_KEY);
|
||||||
|
try {
|
||||||
|
return defaultsSchema.validateSync(value);
|
||||||
|
} catch (e) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredDefaults(data) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DEFAULTS_KEY, JSON.stringify(defaultsSchema.cast(data, {stripUnknown: true})));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error setting stored defaults: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredRecordedTime() {
|
||||||
|
const info = getJsonValue(RECORDED_KEY, { recorded: '', breakTaken: false, updated: null });
|
||||||
|
try {
|
||||||
|
const data = recordedSchema.validateSync(info);
|
||||||
|
if (data.updated != null) {
|
||||||
|
const startOfDay = new Date();
|
||||||
|
startOfDay.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (data.updated < startOfDay.valueOf()) {
|
||||||
|
return { recorded: '0', breakTaken: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
recorded: data.recorded,
|
||||||
|
breakTaken: data.breakTaken,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return { recorded: '0', breakTaken: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredRecordedTime(recorded, breakTaken) {
|
||||||
|
localStorage.setItem(RECORDED_KEY, JSON.stringify({ recorded, breakTaken, updated: new Date().valueOf() }));
|
||||||
|
}
|
||||||
@@ -40,10 +40,13 @@ export function minsToTime(totalMinutes) {
|
|||||||
let hours = Math.floor(absMinutes / 60);
|
let hours = Math.floor(absMinutes / 60);
|
||||||
let minutes = absMinutes % 60;
|
let minutes = absMinutes % 60;
|
||||||
if (minutes > 0) {
|
if (minutes > 0) {
|
||||||
|
if (hours > 0) {
|
||||||
return `${prefix}${hours}h ${minutes}m`;
|
return `${prefix}${hours}h ${minutes}m`;
|
||||||
}
|
}
|
||||||
if (hours > 0) {
|
return `${prefix}${minutes}m`;
|
||||||
return `${hours}h`;
|
|
||||||
}
|
}
|
||||||
return '0m';
|
if (hours > 0) {
|
||||||
|
return `${prefix}${hours}h`;
|
||||||
|
}
|
||||||
|
return `${prefix}0m`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user