2.2.1 - Misc improvements and cleanup
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s

This commit is contained in:
2026-01-19 21:08:46 -07:00
parent 8a3932a013
commit df82a02f41
25 changed files with 149 additions and 153 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "chronus", "name": "chronus",
"type": "module", "type": "module",
"version": "2.2.0", "version": "2.2.1",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",

31
pnpm-lock.yaml generated
View File

@@ -1711,8 +1711,8 @@ packages:
supports-color: supports-color:
optional: true optional: true
decode-named-character-reference@1.2.0: decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==} resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
decompress-response@6.0.0: decompress-response@6.0.0:
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
@@ -2366,6 +2366,7 @@ packages:
libsql@0.5.22: libsql@0.5.22:
resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==} resolution: {integrity: sha512-NscWthMQt7fpU8lqd7LXMvT9pi+KhhmTHAJWUB/Lj6MWa0MKFv0F2V4C6WKKpjCVZl0VwcDz4nOI3CyaT1DDiA==}
cpu: [x64, arm64, wasm32, arm]
os: [darwin, linux, win32] os: [darwin, linux, win32]
lightningcss-android-arm64@1.30.2: lightningcss-android-arm64@1.30.2:
@@ -2672,8 +2673,8 @@ packages:
nlcst-to-string@4.0.0: nlcst-to-string@4.0.0:
resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==}
node-abi@3.86.0: node-abi@3.87.0:
resolution: {integrity: sha512-sn9Et4N3ynsetj3spsZR729DVlGH6iBG4RiDMV7HEp3guyOW6W3S0unGpLDxT50mXortGUMax/ykUNQXdqc/Xg==} resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
node-domexception@1.0.0: node-domexception@1.0.0:
@@ -3157,8 +3158,8 @@ packages:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
tar@7.5.3: tar@7.5.4:
resolution: {integrity: sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==} resolution: {integrity: sha512-AN04xbWGrSTDmVwlI4/GTlIIwMFk/XEv7uL8aa57zuvRy6s4hdBed+lVq2fAZ89XDa7Us3ANXcE3Tvqvja1kTA==}
engines: {node: '>=18'} engines: {node: '>=18'}
tiny-inflate@1.0.3: tiny-inflate@1.0.3:
@@ -4246,7 +4247,7 @@ snapshots:
local-pkg: 1.1.2 local-pkg: 1.1.2
pathe: 2.0.3 pathe: 2.0.3
svgo: 3.3.2 svgo: 3.3.2
tar: 7.5.3 tar: 7.5.4
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5522,7 +5523,7 @@ snapshots:
dependencies: dependencies:
ms: 2.1.3 ms: 2.1.3
decode-named-character-reference@1.2.0: decode-named-character-reference@1.3.0:
dependencies: dependencies:
character-entities: 2.0.2 character-entities: 2.0.2
@@ -6268,7 +6269,7 @@ snapshots:
dependencies: dependencies:
'@types/mdast': 4.0.4 '@types/mdast': 4.0.4
'@types/unist': 3.0.3 '@types/unist': 3.0.3
decode-named-character-reference: 1.2.0 decode-named-character-reference: 1.3.0
devlop: 1.1.0 devlop: 1.1.0
mdast-util-to-string: 4.0.0 mdast-util-to-string: 4.0.0
micromark: 4.0.2 micromark: 4.0.2
@@ -6381,7 +6382,7 @@ snapshots:
micromark-core-commonmark@2.0.3: micromark-core-commonmark@2.0.3:
dependencies: dependencies:
decode-named-character-reference: 1.2.0 decode-named-character-reference: 1.3.0
devlop: 1.1.0 devlop: 1.1.0
micromark-factory-destination: 2.0.1 micromark-factory-destination: 2.0.1
micromark-factory-label: 2.0.1 micromark-factory-label: 2.0.1
@@ -6514,7 +6515,7 @@ snapshots:
micromark-util-decode-string@2.0.1: micromark-util-decode-string@2.0.1:
dependencies: dependencies:
decode-named-character-reference: 1.2.0 decode-named-character-reference: 1.3.0
micromark-util-character: 2.1.1 micromark-util-character: 2.1.1
micromark-util-decode-numeric-character-reference: 2.0.2 micromark-util-decode-numeric-character-reference: 2.0.2
micromark-util-symbol: 2.0.1 micromark-util-symbol: 2.0.1
@@ -6552,7 +6553,7 @@ snapshots:
dependencies: dependencies:
'@types/debug': 4.1.12 '@types/debug': 4.1.12
debug: 4.4.3 debug: 4.4.3
decode-named-character-reference: 1.2.0 decode-named-character-reference: 1.3.0
devlop: 1.1.0 devlop: 1.1.0
micromark-core-commonmark: 2.0.3 micromark-core-commonmark: 2.0.3
micromark-factory-space: 2.0.1 micromark-factory-space: 2.0.1
@@ -6623,7 +6624,7 @@ snapshots:
dependencies: dependencies:
'@types/nlcst': 2.0.3 '@types/nlcst': 2.0.3
node-abi@3.86.0: node-abi@3.87.0:
dependencies: dependencies:
semver: 7.7.3 semver: 7.7.3
optional: true optional: true
@@ -6809,7 +6810,7 @@ snapshots:
minimist: 1.2.8 minimist: 1.2.8
mkdirp-classic: 0.5.3 mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0 napi-build-utils: 2.0.0
node-abi: 3.86.0 node-abi: 3.87.0
pump: 3.0.3 pump: 3.0.3
rc: 1.2.8 rc: 1.2.8
simple-get: 4.0.1 simple-get: 4.0.1
@@ -7240,7 +7241,7 @@ snapshots:
readable-stream: 3.6.2 readable-stream: 3.6.2
optional: true optional: true
tar@7.5.3: tar@7.5.4:
dependencies: dependencies:
'@isaacs/fs-minipass': 4.0.1 '@isaacs/fs-minipass': 4.0.1
chownr: 3.0.0 chownr: 3.0.0

View File

@@ -1,12 +1,12 @@
<template> <template>
<div style="position: relative; height: 100%; width: 100%;"> <div style="position: relative; height: 100%; width: 100%">
<Bar :data="chartData" :options="chartOptions" /> <Bar :data="chartData" :options="chartOptions" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from "vue";
import { Bar } from 'vue-chartjs'; import { Bar } from "vue-chartjs";
import { import {
Chart as ChartJS, Chart as ChartJS,
BarElement, BarElement,
@@ -14,10 +14,18 @@ import {
LinearScale, LinearScale,
Tooltip, Tooltip,
Legend, Legend,
BarController BarController,
} from 'chart.js'; type ChartOptions,
} from "chart.js";
ChartJS.register(BarElement, CategoryScale, LinearScale, Tooltip, Legend, BarController); ChartJS.register(
BarElement,
CategoryScale,
LinearScale,
Tooltip,
Legend,
BarController,
);
interface ClientData { interface ClientData {
name: string; name: string;
@@ -29,57 +37,61 @@ const props = defineProps<{
}>(); }>();
const chartData = computed(() => ({ const chartData = computed(() => ({
labels: props.clients.map(c => c.name), labels: props.clients.map((c) => c.name),
datasets: [{ datasets: [
label: 'Time Tracked', {
data: props.clients.map(c => c.totalTime / (1000 * 60)), // Convert to minutes label: "Time Tracked",
backgroundColor: '#6366f1', data: props.clients.map((c) => c.totalTime / (1000 * 60)), // Convert to minutes
borderColor: '#4f46e5', backgroundColor: "#6366f1",
borderColor: "#4f46e5",
borderWidth: 1, borderWidth: 1,
}] },
],
})); }));
const chartOptions = { const chartOptions: ChartOptions<"bar"> = {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
scales: { scales: {
y: { y: {
beginAtZero: true, beginAtZero: true,
ticks: { ticks: {
color: '#e2e8f0', color: "#e2e8f0",
callback: function(value: number) { callback: function (value: string | number) {
const hours = Math.floor(value / 60); const numValue =
const mins = value % 60; 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`; return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
} },
}, },
grid: { grid: {
color: '#334155' color: "#334155",
} },
}, },
x: { x: {
ticks: { ticks: {
color: '#e2e8f0' color: "#e2e8f0",
}, },
grid: { grid: {
display: false display: false,
} },
} },
}, },
plugins: { plugins: {
legend: { legend: {
display: false display: false,
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
label: function(context: any) { label: function (context) {
const minutes = Math.round(context.raw); const minutes = Math.round(context.raw as number);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const mins = minutes % 60; const mins = minutes % 60;
return ` ${hours}h ${mins}m`; return ` ${hours}h ${mins}m`;
} },
} },
} },
} },
}; };
</script> </script>

View File

@@ -23,7 +23,6 @@ const isSubmitting = ref(false);
const error = ref(""); const error = ref("");
const success = ref(false); const success = ref(false);
// Set default dates to today
const today = new Date().toISOString().split("T")[0]; const today = new Date().toISOString().split("T")[0];
startDate.value = today; startDate.value = today;
endDate.value = today; endDate.value = today;
@@ -114,12 +113,10 @@ async function submitManualEntry() {
if (res.ok) { if (res.ok) {
success.value = true; success.value = true;
// Calculate duration for success message
const start = new Date(startDateTime); const start = new Date(startDateTime);
const end = new Date(endDateTime); const end = new Date(endDateTime);
const duration = formatDuration(start, end); const duration = formatDuration(start, end);
// Reset form
description.value = ""; description.value = "";
selectedClientId.value = ""; selectedClientId.value = "";
selectedCategoryId.value = ""; selectedCategoryId.value = "";
@@ -129,7 +126,6 @@ async function submitManualEntry() {
startTime.value = ""; startTime.value = "";
endTime.value = ""; endTime.value = "";
// Emit event and reload after a short delay
setTimeout(() => { setTimeout(() => {
emit("entryCreated"); emit("entryCreated");
window.location.reload(); window.location.reload();

View File

@@ -58,9 +58,11 @@ const chartOptions: ChartOptions<"bar"> = {
beginAtZero: true, beginAtZero: true,
ticks: { ticks: {
color: "#e2e8f0", color: "#e2e8f0",
callback: function (value: any) { callback: function (value: string | number) {
const hours = Math.floor(value / 60); const numValue =
const mins = value % 60; 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`; return hours > 0 ? `${hours}h ${mins}m` : `${mins}m`;
}, },
}, },
@@ -83,8 +85,8 @@ const chartOptions: ChartOptions<"bar"> = {
}, },
tooltip: { tooltip: {
callbacks: { callbacks: {
label: function (context: any) { label: function (context) {
const minutes = Math.round(context.raw); const minutes = Math.round(context.raw as number);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
const mins = minutes % 60; const mins = minutes % 60;
return ` ${hours}h ${mins}m`; return ` ${hours}h ${mins}m`;

View File

@@ -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")}`; 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 totalMinutes = Math.round(ms / 1000 / 60);
const roundedHours = Math.floor(totalMinutes / 60); const roundedHours = Math.floor(totalMinutes / 60);
const roundedMinutes = totalMinutes % 60; const roundedMinutes = totalMinutes % 60;

View File

@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from "vue";
import { Icon } from '@iconify/vue'; import { Icon } from "@iconify/vue";
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from "@simplewebauthn/browser";
const loading = ref(false); const loading = ref(false);
const error = ref<string | null>(null); const error = ref<string | null>(null);
@@ -11,45 +11,41 @@ async function handlePasskeyLogin() {
error.value = null; error.value = null;
try { 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) { if (!resp.ok) {
throw new Error('Failed to start passkey login'); throw new Error("Failed to start passkey login");
} }
const options = await resp.json(); const options = await resp.json();
// 2. Browser handles interaction
let asseResp; let asseResp;
try { try {
asseResp = await startAuthentication(options); asseResp = await startAuthentication({ optionsJSON: options });
} catch (err) { } catch (err) {
if ((err as any).name === 'NotAllowedError') { if ((err as any).name === "NotAllowedError") {
// User cancelled or timed out
return; return;
} }
console.error(err); console.error(err);
error.value = 'Failed to authenticate with passkey'; error.value = "Failed to authenticate with passkey";
return; return;
} }
// 3. Verify with server const verificationResp = await fetch("/api/auth/passkey/login/finish", {
const verificationResp = await fetch('/api/auth/passkey/login/finish', { method: "POST",
method: 'POST', headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(asseResp), body: JSON.stringify(asseResp),
}); });
const verificationJSON = await verificationResp.json(); const verificationJSON = await verificationResp.json();
if (verificationJSON.verified) { if (verificationJSON.verified) {
window.location.href = '/dashboard'; window.location.href = "/dashboard";
} else { } else {
error.value = 'Login failed. Please try again.'; error.value = "Login failed. Please try again.";
} }
} catch (err) { } catch (err) {
console.error('Error during passkey login:', err); console.error("Error during passkey login:", err);
error.value = 'An error occurred during login'; error.value = "An error occurred during login";
} finally { } finally {
loading.value = false; loading.value = false;
} }

View File

@@ -48,7 +48,6 @@ async function createToken() {
const { token, ...tokenMeta } = data; const { token, ...tokenMeta } = data;
// Add to beginning of list
tokens.value.unshift({ tokens.value.unshift({
id: tokenMeta.id, id: tokenMeta.id,
name: tokenMeta.name, name: tokenMeta.name,

View File

@@ -31,7 +31,6 @@ function formatDate(dateString: string | null) {
async function registerPasskey() { async function registerPasskey() {
loading.value = true; loading.value = true;
try { try {
// 1. Get options from server
const resp = await fetch("/api/auth/passkey/register/start"); const resp = await fetch("/api/auth/passkey/register/start");
if (!resp.ok) { if (!resp.ok) {
@@ -40,13 +39,11 @@ async function registerPasskey() {
const options = await resp.json(); const options = await resp.json();
// 2. Browser handles interaction
let attResp; let attResp;
try { try {
attResp = await startRegistration(options); attResp = await startRegistration({ optionsJSON: options });
} catch (error) { } catch (error) {
if ((error as any).name === "NotAllowedError") { if ((error as any).name === "NotAllowedError") {
// User cancelled or timed out
return; return;
} }
console.error(error); console.error(error);
@@ -54,7 +51,6 @@ async function registerPasskey() {
return; return;
} }
// 3. Verify with server
const verificationResp = await fetch("/api/auth/passkey/register/finish", { const verificationResp = await fetch("/api/auth/passkey/register/finish", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -63,8 +59,6 @@ async function registerPasskey() {
const verificationJSON = await verificationResp.json(); const verificationJSON = await verificationResp.json();
if (verificationJSON.verified) { 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(); window.location.reload();
} else { } else {
alert("Passkey registration failed"); alert("Passkey registration failed");
@@ -88,7 +82,6 @@ async function deletePasskey(id: string) {
}); });
if (response.ok) { if (response.ok) {
// Optimistically remove from list
passkeys.value = passkeys.value.filter((pk) => pk.id !== id); passkeys.value = passkeys.value.filter((pk) => pk.id !== id);
} else { } else {
alert("Failed to delete passkey"); alert("Failed to delete passkey");

View File

@@ -37,12 +37,10 @@ async function changePassword() {
if (response.ok) { if (response.ok) {
message.value = { type: 'success', text: 'Password changed successfully!' }; message.value = { type: 'success', text: 'Password changed successfully!' };
// Reset form
currentPassword.value = ''; currentPassword.value = '';
newPassword.value = ''; newPassword.value = '';
confirmPassword.value = ''; confirmPassword.value = '';
// Hide success message after 3 seconds
setTimeout(() => { setTimeout(() => {
message.value = null; message.value = null;
}, 3000); }, 3000);

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from "vue";
import { Icon } from '@iconify/vue'; import { Icon } from "@iconify/vue";
const props = defineProps<{ const props = defineProps<{
user: { user: {
@@ -12,33 +12,38 @@ const props = defineProps<{
const name = ref(props.user.name); const name = ref(props.user.name);
const loading = ref(false); 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() { async function updateProfile() {
loading.value = true; loading.value = true;
message.value = null; message.value = null;
try { try {
const response = await fetch('/api/user/update-profile', { const response = await fetch("/api/user/update-profile", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/json', "Content-Type": "application/json",
}, },
body: JSON.stringify({ name: name.value }), body: JSON.stringify({ name: name.value }),
}); });
if (response.ok) { if (response.ok) {
message.value = { type: 'success', text: 'Profile updated successfully!' }; message.value = {
// Hide success message after 3 seconds type: "success",
text: "Profile updated successfully!",
};
setTimeout(() => { setTimeout(() => {
message.value = null; message.value = null;
}, 3000); }, 3000);
} else { } else {
const data = await response.json().catch(() => ({})); 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) { } catch (error) {
message.value = { type: 'error', text: 'An error occurred' }; message.value = { type: "error", text: "An error occurred" };
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -48,8 +53,21 @@ async function updateProfile() {
<template> <template>
<div> <div>
<!-- Success/Error Message Display --> <!-- Success/Error Message Display -->
<div v-if="message" :class="['alert mb-6', message.type === 'success' ? 'alert-success' : 'alert-error']"> <div
<Icon :icon="message.type === 'success' ? 'heroicons:check-circle' : 'heroicons:exclamation-circle'" class="w-6 h-6 shrink-0" /> 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> <span>{{ message.text }}</span>
</div> </div>
@@ -63,7 +81,9 @@ async function updateProfile() {
<form @submit.prevent="updateProfile" class="space-y-5"> <form @submit.prevent="updateProfile" class="space-y-5">
<div class="form-control"> <div class="form-control">
<label class="label pb-2"> <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> </label>
<input <input
type="text" type="text"
@@ -76,7 +96,9 @@ async function updateProfile() {
<div class="form-control"> <div class="form-control">
<label class="label pb-2"> <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> </label>
<input <input
type="email" type="email"
@@ -85,13 +107,23 @@ async function updateProfile() {
disabled disabled
/> />
<div class="label pt-2"> <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> </div>
<div class="flex justify-end pt-4"> <div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary w-full sm:w-auto" :disabled="loading"> <button
<span v-if="loading" class="loading loading-spinner loading-sm"></span> 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" /> <Icon v-else icon="heroicons:check" class="w-5 h-5" />
Save Changes Save Changes
</button> </button>

View File

@@ -4,7 +4,6 @@ import * as schema from "./schema";
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
// Define the database type based on the schema
type Database = ReturnType<typeof drizzle<typeof schema>>; type Database = ReturnType<typeof drizzle<typeof schema>>;
let _db: Database | null = null; let _db: Database | null = null;

View File

@@ -270,19 +270,19 @@ export const invoices = sqliteTable(
organizationId: text("organization_id").notNull(), organizationId: text("organization_id").notNull(),
clientId: text("client_id").notNull(), clientId: text("client_id").notNull(),
number: text("number").notNull(), number: text("number").notNull(),
type: text("type").notNull().default("invoice"), // 'invoice' or 'quote' type: text("type").notNull().default("invoice"),
status: text("status").notNull().default("draft"), // 'draft', 'sent', 'paid', 'void', 'accepted', 'declined' status: text("status").notNull().default("draft"),
issueDate: integer("issue_date", { mode: "timestamp" }).notNull(), issueDate: integer("issue_date", { mode: "timestamp" }).notNull(),
dueDate: integer("due_date", { mode: "timestamp" }).notNull(), dueDate: integer("due_date", { mode: "timestamp" }).notNull(),
notes: text("notes"), notes: text("notes"),
currency: text("currency").default("USD").notNull(), currency: text("currency").default("USD").notNull(),
subtotal: integer("subtotal").notNull().default(0), // in cents subtotal: integer("subtotal").notNull().default(0),
discountValue: real("discount_value").default(0), discountValue: real("discount_value").default(0),
discountType: text("discount_type").default("percentage"), // 'percentage' or 'fixed' discountType: text("discount_type").default("percentage"),
discountAmount: integer("discount_amount").default(0), // in cents discountAmount: integer("discount_amount").default(0),
taxRate: real("tax_rate").default(0), // percentage taxRate: real("tax_rate").default(0),
taxAmount: integer("tax_amount").notNull().default(0), // in cents taxAmount: integer("tax_amount").notNull().default(0),
total: integer("total").notNull().default(0), // in cents total: integer("total").notNull().default(0),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(), () => new Date(),
), ),
@@ -312,8 +312,8 @@ export const invoiceItems = sqliteTable(
invoiceId: text("invoice_id").notNull(), invoiceId: text("invoice_id").notNull(),
description: text("description").notNull(), description: text("description").notNull(),
quantity: real("quantity").notNull().default(1), quantity: real("quantity").notNull().default(1),
unitPrice: integer("unit_price").notNull().default(0), // in cents unitPrice: integer("unit_price").notNull().default(0),
amount: integer("amount").notNull().default(0), // in cents amount: integer("amount").notNull().default(0),
}, },
(table: any) => ({ (table: any) => ({
invoiceFk: foreignKey({ invoiceFk: foreignKey({
@@ -327,13 +327,13 @@ export const invoiceItems = sqliteTable(
export const passkeys = sqliteTable( export const passkeys = sqliteTable(
"passkeys", "passkeys",
{ {
id: text("id").primaryKey(), // The Credential ID id: text("id").primaryKey(),
userId: text("user_id").notNull(), userId: text("user_id").notNull(),
publicKey: text("public_key").notNull(), // Base64 encoded public key publicKey: text("public_key").notNull(),
counter: integer("counter").notNull(), counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(), // 'singleDevice' or 'multiDevice' deviceType: text("device_type").notNull(),
backedUp: integer("backed_up", { mode: "boolean" }).notNull(), backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
transports: text("transports"), // JSON stringified array transports: text("transports"),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }), lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn( createdAt: integer("created_at", { mode: "timestamp" }).$defaultFn(
() => new Date(), () => new Date(),

View File

@@ -18,7 +18,6 @@ if (!user) {
return Astro.redirect('/login'); return Astro.redirect('/login');
} }
// Get user's team memberships
const userMemberships = await db.select({ const userMemberships = await db.select({
membership: members, membership: members,
organization: organizations, organization: organizations,
@@ -28,7 +27,6 @@ const userMemberships = await db.select({
.where(eq(members.userId, user.id)) .where(eq(members.userId, user.id))
.all(); .all();
// Get current team from cookie or use first membership
const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id; const currentTeamId = Astro.cookies.get('currentTeamId')?.value || userMemberships[0]?.organization.id;
const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId); const currentTeam = userMemberships.find(m => m.organization.id === currentTeamId);
--- ---

View File

@@ -24,7 +24,6 @@ export async function validateApiToken(token: string) {
return null; return null;
} }
// Update last used at
await db await db
.update(apiTokens) .update(apiTokens)
.set({ lastUsedAt: new Date() }) .set({ lastUsedAt: new Date() })

View File

@@ -4,7 +4,6 @@
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)" * @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
*/ */
export function formatDuration(ms: number): string { export function formatDuration(ms: number): string {
// Calculate rounded version for easy reading
const totalMinutes = Math.round(ms / 1000 / 60); const totalMinutes = Math.round(ms / 1000 / 60);
const hours = Math.floor(totalMinutes / 60); const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60; const minutes = totalMinutes % 60;

View File

@@ -13,7 +13,6 @@ export const GET: APIRoute = async ({ request, locals }) => {
}); });
} }
// Get user's existing passkeys to prevent registering the same authenticator twice
const userPasskeys = await db.query.passkeys.findMany({ const userPasskeys = await db.query.passkeys.findMany({
where: eq(passkeys.userId, user.id), where: eq(passkeys.userId, user.id),
}); });

View File

@@ -14,7 +14,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
return new Response("Invoice ID required", { status: 400 }); return new Response("Invoice ID required", { status: 400 });
} }
// Fetch invoice to verify existence
const invoice = await db const invoice = await db
.select() .select()
.from(invoices) .from(invoices)
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
}); });
} }
// Verify membership
const membership = await db const membership = await db
.select() .select()
.from(members) .from(members)
@@ -48,7 +46,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
} }
try { try {
// Generate next invoice number
const lastInvoice = await db const lastInvoice = await db
.select() .select()
.from(invoices) .from(invoices)
@@ -74,11 +71,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
} }
} }
// Convert quote to invoice:
// 1. Change type to 'invoice'
// 2. Set status to 'draft' (so user can review before sending)
// 3. Update number to next invoice sequence
// 4. Update issue date to today
await db await db
.update(invoices) .update(invoices)
.set({ .set({

View File

@@ -20,7 +20,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
// Fetch invoice with related data
const invoiceResult = await db const invoiceResult = await db
.select({ .select({
invoice: invoices, invoice: invoices,
@@ -39,7 +38,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
const { invoice, client, organization } = invoiceResult; const { invoice, client, organization } = invoiceResult;
// Verify access
const membership = await db const membership = await db
.select() .select()
.from(members) .from(members)
@@ -55,7 +53,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
return new Response("Forbidden", { status: 403 }); return new Response("Forbidden", { status: 403 });
} }
// Fetch items
const items = await db const items = await db
.select() .select()
.from(invoiceItems) .from(invoiceItems)
@@ -66,8 +63,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
return new Response("Client not found", { status: 404 }); return new Response("Client not found", { status: 404 });
} }
// Generate PDF using Vue PDF
// Suppress verbose logging from PDF renderer
const originalConsoleLog = console.log; const originalConsoleLog = console.log;
const originalConsoleWarn = console.warn; const originalConsoleWarn = console.warn;
console.log = () => {}; console.log = () => {};
@@ -83,7 +78,6 @@ export const GET: APIRoute = async ({ params, locals }) => {
const stream = await renderToStream(pdfDocument); const stream = await renderToStream(pdfDocument);
// Restore console.log
console.log = originalConsoleLog; console.log = originalConsoleLog;
console.warn = originalConsoleWarn; console.warn = originalConsoleWarn;

View File

@@ -64,7 +64,6 @@ export const POST: APIRoute = async ({
const quantity = parseFloat(quantityStr); const quantity = parseFloat(quantityStr);
const unitPriceMajor = parseFloat(unitPriceStr); const unitPriceMajor = parseFloat(unitPriceStr);
// Convert to cents
const unitPrice = Math.round(unitPriceMajor * 100); const unitPrice = Math.round(unitPriceMajor * 100);
const amount = Math.round(quantity * unitPrice); const amount = Math.round(quantity * unitPrice);
@@ -77,7 +76,6 @@ export const POST: APIRoute = async ({
amount, amount,
}); });
// Update invoice totals
await recalculateInvoiceTotals(invoiceId); await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`); return redirect(`/dashboard/invoices/${invoiceId}`);

View File

@@ -20,7 +20,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice ID required", { status: 400 }); return new Response("Invoice ID required", { status: 400 });
} }
// Fetch invoice to verify existence and check status
const invoice = await db const invoice = await db
.select() .select()
.from(invoices) .from(invoices)
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice not found", { status: 404 }); return new Response("Invoice not found", { status: 404 });
} }
// Verify membership
const membership = await db const membership = await db
.select() .select()
.from(members) .from(members)
@@ -47,7 +45,6 @@ export const POST: APIRoute = async ({
return new Response("Unauthorized", { status: 401 }); return new Response("Unauthorized", { status: 401 });
} }
// Only allow editing if draft
if (invoice.status !== "draft") { if (invoice.status !== "draft") {
return new Response("Cannot edit a finalized invoice", { status: 400 }); return new Response("Cannot edit a finalized invoice", { status: 400 });
} }
@@ -59,7 +56,6 @@ export const POST: APIRoute = async ({
return new Response("Item ID required", { status: 400 }); return new Response("Item ID required", { status: 400 });
} }
// Verify item belongs to invoice
const item = await db const item = await db
.select() .select()
.from(invoiceItems) .from(invoiceItems)
@@ -73,7 +69,6 @@ export const POST: APIRoute = async ({
try { try {
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId)); await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
// Update invoice totals
await recalculateInvoiceTotals(invoiceId); await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`); return redirect(`/dashboard/invoices/${invoiceId}`);

View File

@@ -35,7 +35,6 @@ export const POST: APIRoute = async ({
return new Response("Invalid status", { status: 400 }); return new Response("Invalid status", { status: 400 });
} }
// Fetch invoice to verify existence and check ownership
const invoice = await db const invoice = await db
.select() .select()
.from(invoices) .from(invoices)
@@ -46,7 +45,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice not found", { status: 404 }); return new Response("Invoice not found", { status: 404 });
} }
// Verify membership
const membership = await db const membership = await db
.select() .select()
.from(members) .from(members)

View File

@@ -20,7 +20,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice ID required", { status: 400 }); return new Response("Invoice ID required", { status: 400 });
} }
// Fetch invoice to verify existence
const invoice = await db const invoice = await db
.select() .select()
.from(invoices) .from(invoices)
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice not found", { status: 404 }); return new Response("Invoice not found", { status: 404 });
} }
// Verify membership
const membership = await db const membership = await db
.select() .select()
.from(members) .from(members)

View File

@@ -294,7 +294,7 @@ const isDraft = invoice.status === 'draft';
<span class="text-base-content/60">Subtotal</span> <span class="text-base-content/60">Subtotal</span>
<span class="font-medium">{formatCurrency(invoice.subtotal)}</span> <span class="font-medium">{formatCurrency(invoice.subtotal)}</span>
</div> </div>
{(invoice.discountAmount > 0) && ( {(invoice.discountAmount && invoice.discountAmount > 0) && (
<div class="flex justify-between text-sm"> <div class="flex justify-between text-sm">
<span class="text-base-content/60"> <span class="text-base-content/60">
Discount Discount

View File

@@ -24,7 +24,6 @@ export async function recalculateInvoiceTotals(invoiceId: string) {
.all(); .all();
// Calculate totals // Calculate totals
// Note: amounts are in cents
const subtotal = items.reduce((acc, item) => acc + item.amount, 0); const subtotal = items.reduce((acc, item) => acc + item.amount, 0);
// Calculate discount // Calculate discount