2 Commits
2.2.0 ... 2.2.1

Author SHA1 Message Date
df82a02f41 2.2.1 - Misc improvements and cleanup
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m3s
2026-01-19 21:08:46 -07:00
8a3932a013 Optimizations
All checks were successful
Docker Deploy / build-and-push (push) Successful in 4m5s
2026-01-19 20:55:47 -07:00
29 changed files with 329 additions and 239 deletions

View File

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

31
pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,72 @@
<script setup lang="ts">
import { ref } from "vue";
import { Icon } from "@iconify/vue";
import { startAuthentication } from "@simplewebauthn/browser";
const loading = ref(false);
const error = ref<string | null>(null);
async function handlePasskeyLogin() {
loading.value = true;
error.value = null;
try {
const resp = await fetch("/api/auth/passkey/login/start");
if (!resp.ok) {
throw new Error("Failed to start passkey login");
}
const options = await resp.json();
let asseResp;
try {
asseResp = await startAuthentication({ optionsJSON: options });
} catch (err) {
if ((err as any).name === "NotAllowedError") {
return;
}
console.error(err);
error.value = "Failed to authenticate with passkey";
return;
}
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";
} else {
error.value = "Login failed. Please try again.";
}
} catch (err) {
console.error("Error during passkey login:", err);
error.value = "An error occurred during login";
} finally {
loading.value = false;
}
}
</script>
<template>
<div>
<button
class="btn btn-secondary w-full"
@click="handlePasskeyLogin"
:disabled="loading"
>
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
<Icon v-else icon="heroicons:finger-print" class="w-5 h-5 mr-2" />
Sign in with Passkey
</button>
<div v-if="error" role="alert" class="alert alert-error mt-4">
<Icon icon="heroicons:exclamation-circle" class="w-6 h-6" />
<span>{{ error }}</span>
</div>
</div>
</template>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
import { ref, onMounted } from "vue";
import { Icon } from "@iconify/vue";
interface ApiToken {
id: string;
@@ -16,12 +16,17 @@ const props = defineProps<{
const tokens = ref<ApiToken[]>(props.initialTokens);
const createModalOpen = ref(false);
const showTokenModalOpen = ref(false);
const newTokenName = ref('');
const newTokenValue = ref('');
const newTokenName = ref("");
const newTokenValue = ref("");
const loading = ref(false);
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
function formatDate(dateString: string | null) {
if (!dateString) return 'Never';
if (!dateString) return "Never";
return new Date(dateString).toLocaleDateString();
}
@@ -30,10 +35,10 @@ async function createToken() {
loading.value = true;
try {
const response = await fetch('/api/user/tokens', {
method: 'POST',
const response = await fetch("/api/user/tokens", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ name: newTokenName.value }),
});
@@ -43,47 +48,50 @@ async function createToken() {
const { token, ...tokenMeta } = data;
// Add to beginning of list
tokens.value.unshift({
id: tokenMeta.id,
name: tokenMeta.name,
lastUsedAt: tokenMeta.lastUsedAt,
createdAt: tokenMeta.createdAt
createdAt: tokenMeta.createdAt,
});
newTokenValue.value = token;
createModalOpen.value = false;
showTokenModalOpen.value = true;
newTokenName.value = '';
newTokenName.value = "";
} else {
alert('Failed to create token');
alert("Failed to create token");
}
} catch (error) {
console.error('Error creating token:', error);
alert('An error occurred');
console.error("Error creating token:", error);
alert("An error occurred");
} finally {
loading.value = false;
}
}
async function deleteToken(id: string) {
if (!confirm('Are you sure you want to revoke this token? Any applications using it will stop working.')) {
if (
!confirm(
"Are you sure you want to revoke this token? Any applications using it will stop working.",
)
) {
return;
}
try {
const response = await fetch(`/api/user/tokens/${id}`, {
method: 'DELETE'
method: "DELETE",
});
if (response.ok) {
tokens.value = tokens.value.filter(t => t.id !== id);
tokens.value = tokens.value.filter((t) => t.id !== id);
} else {
alert('Failed to delete token');
alert("Failed to delete token");
}
} catch (error) {
console.error('Error deleting token:', error);
alert('An error occurred');
console.error("Error deleting token:", error);
alert("An error occurred");
}
}
@@ -93,7 +101,7 @@ function copyToken() {
function closeShowTokenModal() {
showTokenModalOpen.value = false;
newTokenValue.value = '';
newTokenValue.value = "";
}
</script>
@@ -103,10 +111,16 @@ function closeShowTokenModal() {
<div class="card-body p-4 sm:p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="card-title text-lg sm:text-xl">
<Icon icon="heroicons:code-bracket-square" class="w-5 h-5 sm:w-6 sm:h-6" />
<Icon
icon="heroicons:code-bracket-square"
class="w-5 h-5 sm:w-6 sm:h-6"
/>
API Tokens
</h2>
<button class="btn btn-primary btn-sm" @click="createModalOpen = true">
<button
class="btn btn-primary btn-sm"
@click="createModalOpen = true"
>
<Icon icon="heroicons:plus" class="w-4 h-4" />
Create Token
</button>
@@ -131,10 +145,16 @@ function closeShowTokenModal() {
<tr v-else v-for="token in tokens" :key="token.id">
<td class="font-medium">{{ token.name }}</td>
<td class="text-sm">
{{ formatDate(token.lastUsedAt) }}
<span v-if="isMounted">{{
formatDate(token.lastUsedAt)
}}</span>
<span v-else>{{ token.lastUsedAt || "Never" }}</span>
</td>
<td class="text-sm">
{{ formatDate(token.createdAt) }}
<span v-if="isMounted">{{
formatDate(token.createdAt)
}}</span>
<span v-else>{{ token.createdAt }}</span>
</td>
<td>
<button
@@ -175,15 +195,24 @@ function closeShowTokenModal() {
</div>
<div class="modal-action">
<button type="button" class="btn" @click="createModalOpen = false">Cancel</button>
<button type="button" class="btn" @click="createModalOpen = false">
Cancel
</button>
<button type="submit" class="btn btn-primary" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-sm"></span>
<span
v-if="loading"
class="loading loading-spinner loading-sm"
></span>
Generate Token
</button>
</div>
</form>
</div>
<form method="dialog" class="modal-backdrop" @click="createModalOpen = false">
<form
method="dialog"
class="modal-backdrop"
@click="createModalOpen = false"
>
<button>close</button>
</form>
</dialog>
@@ -196,10 +225,13 @@ function closeShowTokenModal() {
Token Created
</h3>
<p class="py-4">
Make sure to copy your personal access token now. You won't be able to see it again!
Make sure to copy your personal access token now. You won't be able to
see it again!
</p>
<div class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group">
<div
class="bg-base-200 p-4 rounded-lg break-all font-mono text-sm relative group"
>
<span>{{ newTokenValue }}</span>
<button
class="absolute top-2 right-2 btn btn-xs btn-ghost opacity-0 group-hover:opacity-100 transition-opacity"
@@ -211,7 +243,9 @@ function closeShowTokenModal() {
</div>
<div class="modal-action">
<button class="btn btn-primary" @click="closeShowTokenModal">Done</button>
<button class="btn btn-primary" @click="closeShowTokenModal">
Done
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop" @click="closeShowTokenModal">

View File

@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
import { startRegistration } from '@simplewebauthn/browser';
import { ref, onMounted } from "vue";
import { Icon } from "@iconify/vue";
import { startRegistration } from "@simplewebauthn/browser";
interface Passkey {
id: string;
@@ -17,16 +17,20 @@ const props = defineProps<{
const passkeys = ref<Passkey[]>(props.initialPasskeys);
const loading = ref(false);
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
function formatDate(dateString: string | null) {
if (!dateString) return 'N/A';
if (!dateString) return "N/A";
return new Date(dateString).toLocaleDateString();
}
async function registerPasskey() {
loading.value = true;
try {
// 1. Get options from server
const resp = await fetch("/api/auth/passkey/register/start");
if (!resp.ok) {
@@ -35,65 +39,56 @@ 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
if ((error as any).name === "NotAllowedError") {
return;
}
console.error(error);
alert('Failed to register passkey: ' + (error as any).message);
alert("Failed to register passkey: " + (error as any).message);
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",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(attResp),
}
);
});
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");
}
} catch (error) {
console.error('Error registering passkey:', error);
alert('An error occurred');
console.error("Error registering passkey:", error);
alert("An error occurred");
} finally {
loading.value = false;
}
}
async function deletePasskey(id: string) {
if (!confirm('Are you sure you want to remove this passkey?')) {
if (!confirm("Are you sure you want to remove this passkey?")) {
return;
}
try {
const response = await fetch(`/api/auth/passkey/delete?id=${id}`, {
method: 'DELETE'
method: "DELETE",
});
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 {
alert('Failed to delete passkey');
alert("Failed to delete passkey");
}
} catch (error) {
console.error('Error deleting passkey:', error);
alert('An error occurred');
console.error("Error deleting passkey:", error);
alert("An error occurred");
}
}
</script>
@@ -106,8 +101,15 @@ async function deletePasskey(id: string) {
<Icon icon="heroicons:finger-print" class="w-5 h-5 sm:w-6 sm:h-6" />
Passkeys
</h2>
<button class="btn btn-primary btn-sm" @click="registerPasskey" :disabled="loading">
<span v-if="loading" class="loading loading-spinner loading-xs"></span>
<button
class="btn btn-primary btn-sm"
@click="registerPasskey"
:disabled="loading"
>
<span
v-if="loading"
class="loading loading-spinner loading-xs"
></span>
<Icon v-else icon="heroicons:plus" class="w-4 h-4" />
Add Passkey
</button>
@@ -131,14 +133,24 @@ async function deletePasskey(id: string) {
</tr>
<tr v-else v-for="pk in passkeys" :key="pk.id">
<td class="font-medium">
{{ pk.deviceType === 'singleDevice' ? 'This Device' : 'Cross-Platform (Phone/Key)' }}
<span v-if="pk.backedUp" class="badge badge-xs badge-info ml-2">Backed Up</span>
{{
pk.deviceType === "singleDevice"
? "This Device"
: "Cross-Platform (Phone/Key)"
}}
<span v-if="pk.backedUp" class="badge badge-xs badge-info ml-2"
>Backed Up</span
>
</td>
<td class="text-sm">
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : 'Never' }}
<span v-if="isMounted">
{{ pk.lastUsedAt ? formatDate(pk.lastUsedAt) : "Never" }}
</span>
<span v-else>{{ pk.lastUsedAt || "Never" }}</span>
</td>
<td class="text-sm">
{{ formatDate(pk.createdAt) }}
<span v-if="isMounted">{{ formatDate(pk.createdAt) }}</span>
<span v-else>{{ pk.createdAt || "N/A" }}</span>
</td>
<td>
<button

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,6 @@
* @returns Formatted string like "01:23:45 (1h 24m)" or "00:05:23 (5m)"
*/
export function formatDuration(ms: number): string {
// Calculate rounded version for easy reading
const totalMinutes = Math.round(ms / 1000 / 60);
const hours = Math.floor(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({
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 });
}
// Fetch invoice to verify existence
const invoice = await db
.select()
.from(invoices)
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
});
}
// Verify membership
const membership = await db
.select()
.from(members)
@@ -48,7 +46,6 @@ export const POST: APIRoute = async ({ redirect, locals, params }) => {
}
try {
// Generate next invoice number
const lastInvoice = await db
.select()
.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
.update(invoices)
.set({

View File

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

View File

@@ -64,7 +64,6 @@ export const POST: APIRoute = async ({
const quantity = parseFloat(quantityStr);
const unitPriceMajor = parseFloat(unitPriceStr);
// Convert to cents
const unitPrice = Math.round(unitPriceMajor * 100);
const amount = Math.round(quantity * unitPrice);
@@ -77,7 +76,6 @@ export const POST: APIRoute = async ({
amount,
});
// Update invoice totals
await recalculateInvoiceTotals(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 });
}
// Fetch invoice to verify existence and check status
const invoice = await db
.select()
.from(invoices)
@@ -31,7 +30,6 @@ export const POST: APIRoute = async ({
return new Response("Invoice not found", { status: 404 });
}
// Verify membership
const membership = await db
.select()
.from(members)
@@ -47,7 +45,6 @@ export const POST: APIRoute = async ({
return new Response("Unauthorized", { status: 401 });
}
// Only allow editing if draft
if (invoice.status !== "draft") {
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 });
}
// Verify item belongs to invoice
const item = await db
.select()
.from(invoiceItems)
@@ -73,7 +69,6 @@ export const POST: APIRoute = async ({
try {
await db.delete(invoiceItems).where(eq(invoiceItems.id, itemId));
// Update invoice totals
await recalculateInvoiceTotals(invoiceId);
return redirect(`/dashboard/invoices/${invoiceId}`);

View File

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

View File

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

View File

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

View File

@@ -554,7 +554,7 @@ function getTimeRangeLabel(range: string) {
</h2>
<div class="h-64 w-full">
<CategoryChart
client:load
client:visible
categories={statsByCategory.filter(s => s.totalTime > 0).map(s => ({
name: s.category.name,
totalTime: s.totalTime,
@@ -576,7 +576,7 @@ function getTimeRangeLabel(range: string) {
</h2>
<div class="h-64 w-full">
<ClientChart
client:load
client:visible
clients={statsByClient.filter(s => s.totalTime > 0).map(s => ({
name: s.client.name,
totalTime: s.totalTime
@@ -598,7 +598,7 @@ function getTimeRangeLabel(range: string) {
</h2>
<div class="h-64 w-full">
<MemberChart
client:load
client:visible
members={statsByMember.filter(s => s.totalTime > 0).map(s => ({
name: s.member.name,
totalTime: s.totalTime

View File

@@ -56,14 +56,14 @@ const userPasskeys = await db.select()
<PasswordForm client:load />
<!-- Passkeys -->
<PasskeyManager client:load initialPasskeys={userPasskeys.map(pk => ({
<PasskeyManager client:idle initialPasskeys={userPasskeys.map(pk => ({
...pk,
lastUsedAt: pk.lastUsedAt ? pk.lastUsedAt.toISOString() : null,
createdAt: pk.createdAt ? pk.createdAt.toISOString() : null
}))} />
<!-- API Tokens -->
<ApiTokenManager client:load initialTokens={userTokens.map(t => ({
<ApiTokenManager client:idle initialTokens={userTokens.map(t => ({
...t,
lastUsedAt: t.lastUsedAt ? t.lastUsedAt.toISOString() : null,
createdAt: t.createdAt ? t.createdAt.toISOString() : ''

View File

@@ -207,7 +207,7 @@ const paginationPages = getPaginationPages(page, totalPages);
</div>
) : (
<ManualEntry
client:load
client:idle
clients={allClients.map(c => ({ id: c.id, name: c.name }))}
categories={allCategories.map(c => ({ id: c.id, name: c.name, color: c.color }))}
tags={allTags.map(t => ({ id: t.id, name: t.name, color: t.color }))}

View File

@@ -1,6 +1,7 @@
---
import Layout from '../layouts/Layout.astro';
import { Icon } from 'astro-icon/components';
import PasskeyLogin from '../components/auth/PasskeyLogin.vue';
if (Astro.locals.user) {
return Astro.redirect('/dashboard');
@@ -16,40 +17,6 @@ const errorMessage =
---
<Layout title="Login - Chronus">
<script>
import { startAuthentication } from "@simplewebauthn/browser";
const loginBtn = document.getElementById("passkey-login");
loginBtn?.addEventListener("click", async () => {
// 1. Get options from server
const resp = await fetch("/api/auth/passkey/login/start");
const options = await resp.json();
// 2. Browser handles interaction
let asseResp;
try {
asseResp = await startAuthentication(options);
} catch (error) {
console.error(error);
return;
}
// 3. Verify with server
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";
} else {
alert("Login failed");
}
});
</script>
<div class="flex justify-center items-center flex-1 bg-base-100">
<div class="card bg-base-100 shadow-2xl w-full max-w-md mx-4">
<div class="card-body">
@@ -94,10 +61,7 @@ const errorMessage =
<button class="btn btn-primary w-full mt-6">Sign In</button>
</form>
<button id="passkey-login" class="btn btn-secondary w-full mt-4">
<Icon name="heroicons:finger-print" class="w-5 h-5 mr-2" />
Sign in with Passkey
</button>
<PasskeyLogin client:idle />
<div class="divider">OR</div>

View File

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