2.2.1 - Misc improvements and cleanup
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div style="position: relative; height: 100%; width: 100%;">
|
||||
<div style="position: relative; height: 100%; width: 100%">
|
||||
<Bar :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Bar } from 'vue-chartjs';
|
||||
import { computed } from "vue";
|
||||
import { Bar } from "vue-chartjs";
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
BarElement,
|
||||
@@ -14,10 +14,18 @@ import {
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarController
|
||||
} from 'chart.js';
|
||||
BarController,
|
||||
type ChartOptions,
|
||||
} from "chart.js";
|
||||
|
||||
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController);
|
||||
ChartJS.register(
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarController,
|
||||
);
|
||||
|
||||
interface ClientData {
|
||||
name: string;
|
||||
@@ -29,57 +37,61 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.clients.map(c => c.name),
|
||||
datasets: [{
|
||||
label: 'Time Tracked',
|
||||
data: props.clients.map(c => c.totalTime / (1000 * 60)), // Convert to minutes
|
||||
backgroundColor: '#6366f1',
|
||||
borderColor: '#4f46e5',
|
||||
borderWidth: 1,
|
||||
}]
|
||||
labels: props.clients.map((c) => c.name),
|
||||
datasets: [
|
||||
{
|
||||
label: "Time Tracked",
|
||||
data: props.clients.map((c) => c.totalTime / (1000 * 60)), // Convert to minutes
|
||||
backgroundColor: "#6366f1",
|
||||
borderColor: "#4f46e5",
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const chartOptions = {
|
||||
const chartOptions: ChartOptions<"bar"> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: '#e2e8f0',
|
||||
callback: function(value: number) {
|
||||
const hours = Math.floor(value / 60);
|
||||
const mins = value % 60;
|
||||
color: "#e2e8f0",
|
||||
callback: function (value: string | number) {
|
||||
const numValue =
|
||||
typeof value === "string" ? parseFloat(value) : value;
|
||||
const hours = Math.floor(numValue / 60);
|
||||
const mins = Math.round(numValue % 60);
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
}
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
color: '#334155'
|
||||
}
|
||||
color: "#334155",
|
||||
},
|
||||
},
|
||||
x: {
|
||||
ticks: {
|
||||
color: '#e2e8f0'
|
||||
color: "#e2e8f0",
|
||||
},
|
||||
grid: {
|
||||
display: false
|
||||
}
|
||||
}
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
const minutes = Math.round(context.raw);
|
||||
label: function (context) {
|
||||
const minutes = Math.round(context.raw as number);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return ` ${hours}h ${mins}m`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -23,7 +23,6 @@ const isSubmitting = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref(false);
|
||||
|
||||
// Set default dates to today
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
startDate.value = today;
|
||||
endDate.value = today;
|
||||
@@ -114,12 +113,10 @@ async function submitManualEntry() {
|
||||
if (res.ok) {
|
||||
success.value = true;
|
||||
|
||||
// Calculate duration for success message
|
||||
const start = new Date(startDateTime);
|
||||
const end = new Date(endDateTime);
|
||||
const duration = formatDuration(start, end);
|
||||
|
||||
// Reset form
|
||||
description.value = "";
|
||||
selectedClientId.value = "";
|
||||
selectedCategoryId.value = "";
|
||||
@@ -129,7 +126,6 @@ async function submitManualEntry() {
|
||||
startTime.value = "";
|
||||
endTime.value = "";
|
||||
|
||||
// Emit event and reload after a short delay
|
||||
setTimeout(() => {
|
||||
emit("entryCreated");
|
||||
window.location.reload();
|
||||
|
||||
@@ -58,9 +58,11 @@ const chartOptions: ChartOptions<"bar"> = {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
color: "#e2e8f0",
|
||||
callback: function (value: any) {
|
||||
const hours = Math.floor(value / 60);
|
||||
const mins = value % 60;
|
||||
callback: function (value: string | number) {
|
||||
const numValue =
|
||||
typeof value === "string" ? parseFloat(value) : value;
|
||||
const hours = Math.floor(numValue / 60);
|
||||
const mins = Math.round(numValue % 60);
|
||||
return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
|
||||
},
|
||||
},
|
||||
@@ -83,8 +85,8 @@ const chartOptions: ChartOptions<"bar"> = {
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context: any) {
|
||||
const minutes = Math.round(context.raw);
|
||||
label: function (context) {
|
||||
const minutes = Math.round(context.raw as number);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return ` ${hours}h ${mins}m`;
|
||||
|
||||
@@ -31,7 +31,6 @@ function formatTime(ms: number) {
|
||||
|
||||
const timeStr = `${hours.toString().padStart(2, "0")}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||
|
||||
// Calculate rounded version
|
||||
const totalMinutes = Math.round(ms / 1000 / 60);
|
||||
const roundedHours = Math.floor(totalMinutes / 60);
|
||||
const roundedMinutes = totalMinutes % 60;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { ref } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
@@ -11,45 +11,41 @@ async function handlePasskeyLogin() {
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
// 1. Get options from server
|
||||
const resp = await fetch('/api/auth/passkey/login/start');
|
||||
const resp = await fetch("/api/auth/passkey/login/start");
|
||||
|
||||
if (!resp.ok) {
|
||||
throw new Error('Failed to start passkey login');
|
||||
throw new Error("Failed to start passkey login");
|
||||
}
|
||||
|
||||
const options = await resp.json();
|
||||
|
||||
// 2. Browser handles interaction
|
||||
let asseResp;
|
||||
try {
|
||||
asseResp = await startAuthentication(options);
|
||||
asseResp = await startAuthentication({ optionsJSON: options });
|
||||
} catch (err) {
|
||||
if ((err as any).name === 'NotAllowedError') {
|
||||
// User cancelled or timed out
|
||||
if ((err as any).name === "NotAllowedError") {
|
||||
return;
|
||||
}
|
||||
console.error(err);
|
||||
error.value = 'Failed to authenticate with passkey';
|
||||
error.value = "Failed to authenticate with passkey";
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Verify with server
|
||||
const verificationResp = await fetch('/api/auth/passkey/login/finish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
const verificationResp = await fetch("/api/auth/passkey/login/finish", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(asseResp),
|
||||
});
|
||||
|
||||
const verificationJSON = await verificationResp.json();
|
||||
if (verificationJSON.verified) {
|
||||
window.location.href = '/dashboard';
|
||||
window.location.href = "/dashboard";
|
||||
} else {
|
||||
error.value = 'Login failed. Please try again.';
|
||||
error.value = "Login failed. Please try again.";
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error during passkey login:', err);
|
||||
error.value = 'An error occurred during login';
|
||||
console.error("Error during passkey login:", err);
|
||||
error.value = "An error occurred during login";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,6 @@ async function createToken() {
|
||||
|
||||
const { token, ...tokenMeta } = data;
|
||||
|
||||
// Add to beginning of list
|
||||
tokens.value.unshift({
|
||||
id: tokenMeta.id,
|
||||
name: tokenMeta.name,
|
||||
|
||||
@@ -31,7 +31,6 @@ function formatDate(dateString: string | null) {
|
||||
async function registerPasskey() {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 1. Get options from server
|
||||
const resp = await fetch("/api/auth/passkey/register/start");
|
||||
|
||||
if (!resp.ok) {
|
||||
@@ -40,13 +39,11 @@ async function registerPasskey() {
|
||||
|
||||
const options = await resp.json();
|
||||
|
||||
// 2. Browser handles interaction
|
||||
let attResp;
|
||||
try {
|
||||
attResp = await startRegistration(options);
|
||||
attResp = await startRegistration({ optionsJSON: options });
|
||||
} catch (error) {
|
||||
if ((error as any).name === "NotAllowedError") {
|
||||
// User cancelled or timed out
|
||||
return;
|
||||
}
|
||||
console.error(error);
|
||||
@@ -54,7 +51,6 @@ async function registerPasskey() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Verify with server
|
||||
const verificationResp = await fetch("/api/auth/passkey/register/finish", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -63,8 +59,6 @@ async function registerPasskey() {
|
||||
|
||||
const verificationJSON = await verificationResp.json();
|
||||
if (verificationJSON.verified) {
|
||||
// Reload to show the new passkey since the API doesn't return the created object
|
||||
// Ideally we would return the object and append it to 'passkeys' to avoid reload
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert("Passkey registration failed");
|
||||
@@ -88,7 +82,6 @@ async function deletePasskey(id: string) {
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Optimistically remove from list
|
||||
passkeys.value = passkeys.value.filter((pk) => pk.id !== id);
|
||||
} else {
|
||||
alert("Failed to delete passkey");
|
||||
|
||||
@@ -37,12 +37,10 @@ async function changePassword() {
|
||||
|
||||
if (response.ok) {
|
||||
message.value = { type: 'success', text: 'Password changed successfully!' };
|
||||
// Reset form
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
|
||||
// Hide success message after 3 seconds
|
||||
setTimeout(() => {
|
||||
message.value = null;
|
||||
}, 3000);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { Icon } from '@iconify/vue';
|
||||
import { ref } from "vue";
|
||||
import { Icon } from "@iconify/vue";
|
||||
|
||||
const props = defineProps<{
|
||||
user: {
|
||||
@@ -12,33 +12,38 @@ const props = defineProps<{
|
||||
|
||||
const name = ref(props.user.name);
|
||||
const loading = ref(false);
|
||||
const message = ref<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const message = ref<{ type: "success" | "error"; text: string } | null>(null);
|
||||
|
||||
async function updateProfile() {
|
||||
loading.value = true;
|
||||
message.value = null;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/user/update-profile', {
|
||||
method: 'POST',
|
||||
const response = await fetch("/api/user/update-profile", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name: name.value }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
message.value = { type: 'success', text: 'Profile updated successfully!' };
|
||||
// Hide success message after 3 seconds
|
||||
message.value = {
|
||||
type: "success",
|
||||
text: "Profile updated successfully!",
|
||||
};
|
||||
setTimeout(() => {
|
||||
message.value = null;
|
||||
}, 3000);
|
||||
} else {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
message.value = { type: 'error', text: data.error || 'Failed to update profile' };
|
||||
message.value = {
|
||||
type: "error",
|
||||
text: data.error || "Failed to update profile",
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
message.value = { type: 'error', text: 'An error occurred' };
|
||||
message.value = { type: "error", text: "An error occurred" };
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -48,8 +53,21 @@ async function updateProfile() {
|
||||
<template>
|
||||
<div>
|
||||
<!-- Success/Error Message Display -->
|
||||
<div v-if="message" :class="['alert mb-6', message.type === 'success' ? 'alert-success' : 'alert-error']">
|
||||
<Icon :icon="message.type === 'success' ? 'heroicons:check-circle' : 'heroicons:exclamation-circle'" class="w-6 h-6 shrink-0" />
|
||||
<div
|
||||
v-if="message"
|
||||
:class="[
|
||||
'alert mb-6',
|
||||
message.type === 'success' ? 'alert-success' : 'alert-error',
|
||||
]"
|
||||
>
|
||||
<Icon
|
||||
:icon="
|
||||
message.type === 'success'
|
||||
? 'heroicons:check-circle'
|
||||
: 'heroicons:exclamation-circle'
|
||||
"
|
||||
class="w-6 h-6 shrink-0"
|
||||
/>
|
||||
<span>{{ message.text }}</span>
|
||||
</div>
|
||||
|
||||
@@ -63,7 +81,9 @@ async function updateProfile() {
|
||||
<form @submit.prevent="updateProfile" class="space-y-5">
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Full Name</span>
|
||||
<span class="label-text font-medium text-sm sm:text-base"
|
||||
>Full Name</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -76,7 +96,9 @@ async function updateProfile() {
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-medium text-sm sm:text-base">Email</span>
|
||||
<span class="label-text font-medium text-sm sm:text-base"
|
||||
>Email</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@@ -85,13 +107,23 @@ async function updateProfile() {
|
||||
disabled
|
||||
/>
|
||||
<div class="label pt-2">
|
||||
<span class="label-text-alt text-base-content/60 text-xs sm:text-sm">Email cannot be changed</span>
|
||||
<span
|
||||
class="label-text-alt text-base-content/60 text-xs sm:text-sm"
|
||||
>Email cannot be changed</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" class="btn btn-primary w-full sm:w-auto" :disabled="loading">
|
||||
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full sm:w-auto"
|
||||
:disabled="loading"
|
||||
>
|
||||
<span
|
||||
v-if="loading"
|
||||
class="loading loading-spinner loading-sm"
|
||||
></span>
|
||||
<Icon v-else icon="heroicons:check" class="w-5 h-5" />
|
||||
Save Changes
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user