mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-29 16:24:22 +10:00
* feat(applications): schedule interviews and view them on a calendar Interviews (screening, technical, behavioral, onsite, other) are stored as "interview" entries on an application's activity timeline, so an application can have any number of them and they show in its timeline without a migration. Each interview has a start date-time (timezone-aware), duration, and optional location and notes. - schema: interview timeline entry type, interviewDetailsSchema, INTERVIEW_KINDS - api: addInterview / updateInterview procedures (delete via timeline entry); generic timeline updates now only edit text on note entries - web: Calendar view on the Applications page (month grid, type legend, upcoming list grouped by day, schedule button with application picker, per-day "+" and "+N more" popover), Interviews section and interview dialog in the application detail panel, interview rows in the timeline and CSV export - mcp: add_application_interview / update_application_interview tools - i18n: extract new strings into all locale catalogs (English fallback) - docs: MCP tool table, scheduling guide section, resume-builder skill Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(applications): keep interview dates in local time and guard generic timeline edits - Format the timeline date chip for interview entries in the viewer's local timezone so it matches the interview's date-time label; stage and note entries still render in UTC. - Reject interview entries in the generic updateTimelineEntry path. A day-granular date edit kept the UTC time of day and could move an interview to the wrong local day; callers are pointed to updateInterview (update_application_interview). The MCP tool description now says so, and a service test covers the rejection. - Associate each interview dialog label with its control via useId/htmlFor. - Drop the duplicate onInput handler on the date-time input; onChange covers controlled inputs. - Give the calendar's per-day schedule button an accessible name that includes the date, and update the extracted locale catalogs for the new message. * [autofix.ci] apply automated fixes --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Amruth Pillai <im.amruth@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
87 lines
3.3 KiB
TypeScript
87 lines
3.3 KiB
TypeScript
import type { InterviewKind, InterviewTimelineEntry } from "@reactive-resume/schema/applications/data";
|
|
import type { Application } from "./types";
|
|
import { INTERVIEW_KINDS } from "@reactive-resume/schema/applications/data";
|
|
|
|
export type ScheduledInterview = {
|
|
application: Application;
|
|
interview: InterviewTimelineEntry;
|
|
start: Date;
|
|
end: Date;
|
|
};
|
|
|
|
export const interviewKindOf = (kind: InterviewKind) => INTERVIEW_KINDS.find((item) => item.value === kind);
|
|
|
|
export const isInterview = (entry: Application["activity"][number]): entry is InterviewTimelineEntry =>
|
|
entry.type === "interview";
|
|
|
|
// Every interview across the given applications, soonest first.
|
|
export function collectInterviews(applications: Application[]): ScheduledInterview[] {
|
|
return applications
|
|
.flatMap((application) =>
|
|
application.activity.filter(isInterview).map((interview) => {
|
|
const start = new Date(interview.at);
|
|
return {
|
|
application,
|
|
interview,
|
|
start,
|
|
end: new Date(start.getTime() + interview.durationMinutes * 60_000),
|
|
};
|
|
}),
|
|
)
|
|
.sort((a, b) => a.start.getTime() - b.start.getTime());
|
|
}
|
|
|
|
// Interviews that haven't finished yet, soonest first.
|
|
export function upcomingInterviews(interviews: ScheduledInterview[], now = new Date()) {
|
|
return interviews.filter((item) => item.end.getTime() >= now.getTime());
|
|
}
|
|
|
|
// "30 min", "1 hr", "1 hr 30 min" — localized unit names via Intl.
|
|
export function formatDuration(minutes: number, locale?: string) {
|
|
const hours = Math.floor(minutes / 60);
|
|
const rest = minutes % 60;
|
|
const unit = (value: number, name: "hour" | "minute") =>
|
|
new Intl.NumberFormat(locale, { style: "unit", unit: name, unitDisplay: "short" }).format(value);
|
|
return [hours > 0 ? unit(hours, "hour") : null, rest > 0 || hours === 0 ? unit(rest, "minute") : null]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
|
|
const pad = (value: number) => String(value).padStart(2, "0");
|
|
|
|
// Local calendar day key (YYYY-MM-DD); interviews are bucketed by the viewer's timezone.
|
|
export const dayKey = (date: Date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
|
|
// Value for <input type="datetime-local"> in the viewer's timezone.
|
|
export function toDateTimeLocal(value: Date | string) {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return "";
|
|
return `${dayKey(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
}
|
|
|
|
// A datetime-local value is interpreted in the viewer's timezone and sent as an absolute ISO timestamp.
|
|
export function fromDateTimeLocal(value: string) {
|
|
if (!value) return null;
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
}
|
|
|
|
// Six full weeks (42 days) covering the month, starting on `weekStartsOn` (0 = Sunday).
|
|
export function monthGrid(month: Date, weekStartsOn = 0): Date[] {
|
|
const first = new Date(month.getFullYear(), month.getMonth(), 1);
|
|
const offset = (first.getDay() - weekStartsOn + 7) % 7;
|
|
return Array.from(
|
|
{ length: 42 },
|
|
(_, i) => new Date(first.getFullYear(), first.getMonth(), first.getDate() - offset + i),
|
|
);
|
|
}
|
|
|
|
export function groupByDay(interviews: ScheduledInterview[]) {
|
|
const map = new Map<string, ScheduledInterview[]>();
|
|
for (const item of interviews) {
|
|
const key = dayKey(item.start);
|
|
map.set(key, [...(map.get(key) ?? []), item]);
|
|
}
|
|
return map;
|
|
}
|