Add automatic lunch feature. Clean up some code.

This commit is contained in:
2025-10-23 15:34:09 -05:00
parent 40489be8e9
commit 7eb3a1dff0
7 changed files with 197 additions and 126 deletions

View File

@@ -1,6 +1,5 @@
'use client'; 'use client';
import Image from 'next/image';
import { type ChangeEvent, useRef, useState } from 'react'; import { type ChangeEvent, useRef, useState } from 'react';
import { import {
type Preloaded, type Preloaded,
@@ -49,7 +48,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const generateUploadUrl = useMutation(api.files.generateUploadUrl); const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const updateUserImage = useMutation(api.auth.updateUserImage); const updateUser = useMutation(api.auth.updateUser);
const currentImageUrl = useQuery( const currentImageUrl = useQuery(
api.files.getImageUrl, api.files.getImageUrl,
@@ -98,7 +97,7 @@ export const AvatarUpload = ({ preloadedUser }: AvatarUploadProps) => {
storageId: Id<'_storage'>; storageId: Id<'_storage'>;
}; };
await updateUserImage({ storageId: uploadResponse.storageId }); await updateUser({ image: uploadResponse.storageId });
toast.success('Profile picture updated.'); toast.success('Profile picture updated.');
handleReset(); handleReset();

View File

@@ -51,12 +51,7 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
const user = usePreloadedQuery(preloadedUser); const user = usePreloadedQuery(preloadedUser);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const updateUserName = useMutation(api.auth.updateUserName); const updateUser = useMutation(api.auth.updateUser);
const updateUserEmail = useMutation(api.auth.updateUserEmail);
const updateUserLunchtime = useMutation(api.auth.updateUserLunchtime);
const updateUserAutomaticLunch = useMutation(
api.auth.updateUserAutomaticLunch,
);
const initialValues = useMemo<z.infer<typeof formSchema>>( const initialValues = useMemo<z.infer<typeof formSchema>>(
() => ({ () => ({
@@ -74,22 +69,29 @@ export const UserInfoForm = ({ preloadedUser }: UserInfoFormProps) => {
}); });
const handleSubmit = async (values: z.infer<typeof formSchema>) => { const handleSubmit = async (values: z.infer<typeof formSchema>) => {
const ops: Promise<unknown>[] = [];
const name = values.name.trim(); const name = values.name.trim();
const email = values.email.trim().toLowerCase(); const email = values.email.trim().toLowerCase();
const lunchTime = values.lunchTime.trim(); const lunchTime = values.lunchTime.trim();
const automaticLunch = values.automaticLunch; const automaticLunch = values.automaticLunch;
if (name !== (user?.name ?? '')) ops.push(updateUserName({ name })); const patch: Partial<{
if (email !== (user?.email ?? '')) ops.push(updateUserEmail({ email })); name: string;
if (lunchTime !== (user?.lunchTime ?? '')) email: string;
ops.push(updateUserLunchtime({ lunchTime })); lunchTime: string;
if (automaticLunch !== user?.automaticLunch) automaticLunch: boolean;
ops.push(updateUserAutomaticLunch({ automaticLunch })); }> = {};
if (ops.length === 0) return; if (name !== (user?.name ?? '') && name !== undefined)
patch.name = name;
if (email !== (user?.email ?? '') && email !== undefined)
patch.email = email;
if (lunchTime !== (user?.lunchTime && '') && lunchTime !== undefined)
patch.lunchTime = lunchTime;
if (automaticLunch !== user?.automaticLunch && automaticLunch !== undefined)
patch.automaticLunch = automaticLunch;
if (Object.keys(patch).length === 0) return;
setLoading(true); setLoading(true);
try { try {
await Promise.all(ops); await updateUser(patch);
form.reset({ name, email, lunchTime }); form.reset(patch);
toast.success('Profile updated successfully.'); toast.success('Profile updated successfully.');
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@@ -16,12 +16,19 @@ const nextOccurrenceMs = (hhmm: string, from = new Date()): number => {
export const LunchReminder = () => { export const LunchReminder = () => {
const setStatus = useMutation(api.statuses.createLunchStatus); const setStatus = useMutation(api.statuses.createLunchStatus);
const timeoutRef = useRef<number | null>(null); const timeoutRef = useRef<number | null>(null);
const user = useQuery(api.auth.getUser); const user = useQuery(api.auth.getUser, {});
const lunchTime = user?.lunchTime ?? ''; const lunchTime = user?.lunchTime ?? '';
const automaticLunch = user?.automaticLunch ?? false;
useEffect(() => { useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
if (!lunchTime || automaticLunch) {
return;
}
const schedule = () => { const schedule = () => {
if (!lunchTime) return;
const ms = nextOccurrenceMs(lunchTime); const ms = nextOccurrenceMs(lunchTime);
console.log('Ms = ', ms); console.log('Ms = ', ms);
if (timeoutRef.current) clearTimeout(timeoutRef.current); if (timeoutRef.current) clearTimeout(timeoutRef.current);
@@ -44,7 +51,7 @@ export const LunchReminder = () => {
description: 'Would you like to set your status to "At lunch"?', description: 'Would you like to set your status to "At lunch"?',
action: { action: {
label: 'Set to lunch', label: 'Set to lunch',
onClick: () => void setStatus(), onClick: () => void setStatus({}),
}, },
cancel: { cancel: {
label: 'Not now', label: 'Not now',
@@ -59,9 +66,11 @@ export const LunchReminder = () => {
schedule(); schedule();
return () => { return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current); if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null
}
}; };
}, [lunchTime, setStatus]); }, [automaticLunch, lunchTime, setStatus]);
return null; return null;
}; };

View File

@@ -1,6 +1,5 @@
'use client'; 'use client';
import { permission } from 'process';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';

View File

@@ -7,7 +7,13 @@ import {
} from '@convex-dev/auth/server'; } from '@convex-dev/auth/server';
import { api } from './_generated/api'; import { api } from './_generated/api';
import { type Id } from './_generated/dataModel'; import { type Id } from './_generated/dataModel';
import { action, mutation, query } from './_generated/server'; import {
action,
mutation,
query,
type MutationCtx,
type QueryCtx,
} from './_generated/server';
import Authentik from '@auth/core/providers/authentik'; import Authentik from '@auth/core/providers/authentik';
import { Entra, Password, validatePassword } from './custom/auth'; import { Entra, Password, validatePassword } from './custom/auth';
@@ -24,127 +30,116 @@ export const PASSWORD_MAX = 100;
export const PASSWORD_REGEX = export const PASSWORD_REGEX =
/^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u; /^(?=.{8,100}$)(?!.*\s)(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[\p{P}\p{S}]).*$/u;
export const getUser = query(async (ctx) => { type RWCtx = MutationCtx | QueryCtx;
const userId = await getAuthUserId(ctx); type User = {
if (!userId) return null; id: Id<'users'>;
email: string | null;
name: string | null;
image: Id<'_storage'> | null;
lunchTime: string | null;
automaticLunch: boolean;
provider: string | null;
};
const getUserData = async (ctx: RWCtx, userId: Id<'users'>): Promise<User> => {
const user = await ctx.db.get(userId); const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
const image: Id<'_storage'> | null = const image: Id<'_storage'> | null =
typeof user.image === 'string' && user.image.length > 0 typeof user.image === 'string' && user.image.length > 0
? (user.image as Id<'_storage'>) ? (user.image as Id<'_storage'>)
: null; : null;
const authAccount = await ctx.db
const authAccount = await getUserAuthAccountData(ctx, userId);
return {
id: user._id,
email: user.email ?? null,
name: user.name ?? null,
image,
lunchTime: user.lunchTime ?? null,
automaticLunch: user.automaticLunch ?? false,
provider: authAccount?.provider ?? null,
};
};
const getUserAuthAccountData = async (ctx: RWCtx, userId: Id<'users'>) => {
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
const authAccountData = await ctx.db
.query('authAccounts') .query('authAccounts')
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId)) .withIndex('userIdAndProvider', (q) => q.eq('userId', userId))
.first(); .first();
return { return authAccountData;
id: user._id,
email: user?.email,
name: user?.name,
image,
lunchTime: user?.lunchTime,
automaticLunch: user.automaticLunch,
provider: authAccount?.provider,
}; };
export const getUser = query({
args: { userId: v.optional(v.id('users')) },
handler: async (ctx, args) => {
const userId = args.userId ?? await getAuthUserId(ctx);
if (!userId) return null;
return getUserData(ctx, userId);
},
}); });
export const getUserAuthAccount = query(async (ctx) => { export const getUserAuthAccount = query(async (ctx) => {
const userId = await getAuthUserId(ctx); const userId = await getAuthUserId(ctx);
if (!userId) return null; if (!userId) return null;
const authAccount = await ctx.db return getUserAuthAccountData(ctx, userId);
.query('authAccounts')
.withIndex('userIdAndProvider', (q) => q.eq('userId', userId))
.first();
return authAccount;
}); });
export const getAllUsers = query(async (ctx) => { export const getAllUsers = query(async (ctx) => {
const users = await ctx.db.query('users').collect(); const users = await ctx.db.query('users').collect();
return users.map((u) => ({ return Promise.all(users.map((u) => getUserData(ctx, u._id)));
id: u._id,
email: u.email ?? null,
name: u.name ?? null,
image: u.image ?? null,
lunchTime: u.lunchTime ?? null,
automaticLUnch: u.automaticLunch ?? (false as boolean),
}));
}); });
export const getAllUserIds = query(async (ctx) => { export const getAllUserIds = query(async (ctx) => {
const users = await ctx.db.query('users').collect(); const users = await ctx.db.query('users').collect();
const userIds = users.map((u) => u._id); return users.map((u) => u._id);
return userIds;
}); });
export const updateUserName = mutation({ export const updateUser = mutation({
args: { args: {
name: v.string(), name: v.optional(v.string()),
email: v.optional(v.string()),
image: v.optional(v.id('_storage')),
lunchTime: v.optional(v.string()),
automaticLunch: v.optional(v.boolean()),
}, },
handler: async (ctx, { name }) => { handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx); const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.'); if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId); const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.'); if (!user) throw new ConvexError('User not found.');
await ctx.db.patch(userId, { name }); if (args.lunchTime !== undefined && !args.lunchTime.includes(':')) {
return { success: true };
},
});
export const updateUserEmail = mutation({
args: {
email: v.string(),
},
handler: async (ctx, { email }) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
await ctx.db.patch(userId, { email });
return { success: true };
},
});
export const updateUserImage = mutation({
args: {
storageId: v.id('_storage'),
},
handler: async (ctx, { storageId }) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
const oldImage = user.image as Id<'_storage'> | undefined;
await ctx.db.patch(userId, { image: storageId });
if (oldImage && oldImage !== storageId) await ctx.storage.delete(oldImage);
return { success: true };
},
});
export const updateUserLunchtime = mutation({
args: {
lunchTime: v.string(),
},
handler: async (ctx, { lunchTime }) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
if (!lunchTime.includes(':'))
throw new ConvexError('Lunch time is invalid.'); throw new ConvexError('Lunch time is invalid.');
await ctx.db.patch(userId, { lunchTime }); }
return { success: true }; const patch: Partial<{
}, name: string;
}); email: string;
image: Id<'_storage'>;
lunchTime: string;
automaticLunch: boolean;
}> = {};
if (args.name !== undefined) patch.name = args.name;
if (args.email !== undefined) patch.email = args.email;
if (args.lunchTime !== undefined) patch.lunchTime = args.lunchTime;
if (args.automaticLunch !== undefined)
patch.automaticLunch = args.automaticLunch;
if (args.image !== undefined) {
const oldImage = user.image as Id<'_storage'> | undefined;
patch.image = args.image;
if (oldImage && oldImage !== args.image) {
await ctx.storage.delete(oldImage);
}
}
if (Object.keys(patch).length > 0) {
await ctx.db.patch(userId, patch);
}
export const updateUserAutomaticLunch = mutation({
args: { automaticLunch: v.boolean() },
handler: async (ctx, { automaticLunch }) => {
const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.db.get(userId);
if (!user) throw new ConvexError('User not found.');
if (user.automaticLunch === automaticLunch) return { success: true };
await ctx.db.patch(userId, { automaticLunch });
return { success: true }; return { success: true };
}, },
}); });
@@ -157,7 +152,7 @@ export const updateUserPassword = action({
handler: async (ctx, { currentPassword, newPassword }) => { handler: async (ctx, { currentPassword, newPassword }) => {
const userId = await getAuthUserId(ctx); const userId = await getAuthUserId(ctx);
if (!userId) throw new ConvexError('Not authenticated.'); if (!userId) throw new ConvexError('Not authenticated.');
const user = await ctx.runQuery(api.auth.getUser); const user = await ctx.runQuery(api.auth.getUser, { userId });
if (!user?.email) throw new ConvexError('User not found.'); if (!user?.email) throw new ConvexError('User not found.');
const verified = await retrieveAccount(ctx, { const verified = await retrieveAccount(ctx, {
provider: 'password', provider: 'password',

View File

@@ -4,10 +4,26 @@ import { api } from './_generated/api';
const crons = cronJobs(); const crons = cronJobs();
// Runs at 5:00 PM America/Chicago, MondayFriday. crons.cron(
// Convex will handle DST if your project version supports `timeZone`. // Run at 7:00 AM CST / 8:00 AM CDT
// Only on weekdays
'Schedule Automatic Lunches',
'0 13 * * 1-5',
api.statuses.automaticLunch,
);
crons.cron(
// Run at 7:00 AM CST / 8:00 AM CDT
// Only on weekdays
'Schedule Automatic Lunches Test',
'25 18 * * 1-5',
api.statuses.automaticLunch,
);
crons.cron( crons.cron(
'End of shift (weekdays 5pm CT)', 'End of shift (weekdays 5pm CT)',
// Run at 4:00 PM CST / 5:00 PM CDT
// Only on weekdays
'0 22 * * 1-5', '0 22 * * 1-5',
api.statuses.endOfShiftUpdate, api.statuses.endOfShiftUpdate,
); );

View File

@@ -156,23 +156,41 @@ export const updateAllStatuses = mutation({
}); });
export const createLunchStatus = mutation({ export const createLunchStatus = mutation({
args: {}, args: { userId: v.optional(v.id('users'))},
handler: async (ctx) => { handler: async (ctx, args) => {
const authUserId = await getAuthUserId(ctx); const authUserId = await getAuthUserId(ctx);
if (!authUserId) throw new ConvexError('Not authenticated.'); const lunchUserId = args.userId ?? authUserId
if (!lunchUserId) throw new ConvexError('Not authenticated.');
await ctx.runMutation(api.statuses.create, { await ctx.runMutation(api.statuses.create, {
message: 'At lunch', message: 'At lunch',
userId: authUserId, userId: lunchUserId,
}); });
const oneHour = 60 * 60 * 1000; const oneHour = 60 * 60 * 1000;
await ctx.scheduler.runAfter(oneHour, api.statuses.create, { await ctx.scheduler.runAfter(oneHour, api.statuses.backFromLunchStatus, {
message: 'At desk', userId: lunchUserId,
userId: authUserId,
}); });
return { success: true }; return { success: true };
}, },
}); });
export const backFromLunchStatus = mutation({
args: { userId: v.optional(v.id('users')) },
handler: async (ctx, args) => {
const authUserId = await getAuthUserId(ctx);
const lunchUserId = args.userId ?? authUserId
if (!lunchUserId) throw new ConvexError('Not authenticated.');
const user = await ensureUser(ctx, lunchUserId);
if (!user.currentStatusId) throw new ConvexError('User has no current status.');
const currentStatus = await ctx.db.get(user.currentStatusId);
if (currentStatus?.message === 'At lunch') {
await ctx.runMutation(api.statuses.create, {
message: 'At desk',
userId: lunchUserId,
});
}
},
});
export const getCurrentForUser = query({ export const getCurrentForUser = query({
args: { userId: v.id('users') }, args: { userId: v.id('users') },
handler: async (ctx, { userId }) => { handler: async (ctx, { userId }) => {
@@ -331,3 +349,36 @@ export const endOfShiftUpdate = action({
} else return; } else return;
}, },
}); });
export const automaticLunch = action({
handler: async (ctx) => {
const now = new Date(
new Date().toLocaleString('en-US', {
timeZone: 'America/Chicago',
}),
);
const users = await ctx.runQuery(api.auth.getAllUsers);
await Promise.all(
users.map(async (user) => {
if (user.automaticLunch && user.lunchTime) {
const [hours, minutes] = user.lunchTime.split(':').map(Number);
const userLunchTime = new Date(now);
userLunchTime.setHours(hours, minutes, 0, 0);
const diffInMs = userLunchTime.getTime() - now.getTime();
// Only schedule if lunch is in the future today
if (diffInMs > 0) {
await ctx.scheduler.runAfter(
diffInMs,
api.statuses.createLunchStatus,
{ userId: user.id },
);
} else {
console.warn(
`Skipped ${user.name} - lunch time ${user.lunchTime} already passed.`
);
}
}
})
);
},
});