Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 36 additions & 4 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import InputForm from './components/InputForm.vue'
import ResultSummary from './components/ResultSummary.vue'
import Timeline from './components/Timeline.vue'
import { useAppStore } from './stores/app'
import { createUnemploymentICS, downloadIcsFile } from './utils/ics'
import { createUnemploymentICS, createStemEvaluationICS, downloadIcsFile } from './utils/ics'

const store = useAppStore()
const showHelpModal = ref(false)
type ToastType = 'success' | 'error'
const copyStatus = ref<'idle' | 'copied' | 'error'>('idle')
const exportStatus = ref<'idle' | 'success' | 'error'>('idle')
const stemEvalStatus = ref<'idle' | 'success' | 'error'>('idle')
const toasts = ref<{ id: number; message: string; type: ToastType }[]>([])
let tempTimer: number | null = null

Expand Down Expand Up @@ -123,6 +124,29 @@ async function exportDeadlines() {
}, 3000)
}
}

async function exportStemEvaluations() {
if (!store.stemPeriod) return
if (tempTimer) {
clearTimeout(tempTimer)
tempTimer = null
}
try {
const ics = createStemEvaluationICS(store.stemPeriod.startDate)
if (!ics) throw new Error('Missing STEM OPT period')
downloadIcsFile(ics, 'stem-opt-evaluations.ics')
stemEvalStatus.value = 'success'
pushToast('STEM evaluation reminders downloaded', 'success')
} catch (error) {
console.error('Failed to export STEM evaluation ICS', error)
stemEvalStatus.value = 'error'
pushToast('Failed to export STEM evaluation reminders', 'error')
} finally {
tempTimer = window.setTimeout(() => {
stemEvalStatus.value = 'idle'
}, 3000)
}
}
</script>

<template>
Expand Down Expand Up @@ -203,10 +227,14 @@ async function exportDeadlines() {
<h2 class="text-xl font-semibold text-gray-900">
Unemployment Summary
</h2>
<div class="flex items-center space-x-2">
<div class="flex items-center space-x-2 flex-wrap gap-2">
<BaseButton variant="secondary" size="sm" :disabled="!store.canCalculate || !store.optPeriod"
@click="exportDeadlines">
📆 Add to Calendar
📆 Deadlines
</BaseButton>
<BaseButton v-if="store.hasStemExtension" variant="secondary" size="sm"
:disabled="!store.stemPeriod" @click="exportStemEvaluations">
📋 STEM Evaluations
</BaseButton>
</div>
</div>
Expand Down Expand Up @@ -276,6 +304,9 @@ async function exportDeadlines() {
<dd>150-day unemployment limit</dd>
</div>
</dl>
<p class="text-slate-600 mt-3 pt-3 border-t border-slate-200">
<strong>STEM OPT Evaluations:</strong> Students on STEM OPT must submit Form I-983 evaluations at 6, 12, 18, and 24 months. Use the "STEM Evaluations" button to download calendar reminders.
</p>
</article>
</section>

Expand All @@ -288,7 +319,8 @@ async function exportDeadlines() {
<li>Add STEM dates if you hold the extension.</li>
<li>Log each employment period (employers, start/end dates).</li>
<li>Review the summary to see days used vs. remaining.</li>
<li>Download ICS reminders or copy the summary for your DSO.</li>
<li>Download unemployment deadline reminders or copy the summary for your DSO.</li>
<li>If on STEM OPT, download evaluation reminders for 6, 12, 18, and 24-month deadlines.</li>
</ol>
</article>
</section>
Expand Down
80 changes: 80 additions & 0 deletions src/utils/__tests__/ics.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest'
import { createStemEvaluationICS } from '../ics'

describe('createStemEvaluationICS', () => {
it('generates ICS with 4 evaluation events', () => {
const stemStartDate = new Date('2024-07-15')
const ics = createStemEvaluationICS(stemStartDate)

expect(ics).not.toBeNull()
expect(ics).toContain('BEGIN:VCALENDAR')
expect(ics).toContain('END:VCALENDAR')
expect(ics).toContain('PRODID:-//OPT Tracker//EN')

// Should have 4 events (6, 12, 18, 24 months)
const eventCount = (ics!.match(/BEGIN:VEVENT/g) || []).length
expect(eventCount).toBe(4)
})

it('includes correct milestone months in event summaries', () => {
const stemStartDate = new Date('2024-07-15')
const ics = createStemEvaluationICS(stemStartDate)

expect(ics).toContain('STEM OPT 6-Month Evaluation Due')
expect(ics).toContain('STEM OPT 12-Month Evaluation Due')
expect(ics).toContain('STEM OPT 18-Month Evaluation Due')
expect(ics).toContain('STEM OPT 24-Month Evaluation Due')
})

it('includes Form I-983 in descriptions', () => {
const stemStartDate = new Date('2024-07-15')
const ics = createStemEvaluationICS(stemStartDate)

expect(ics).toContain('Form I-983')
expect(ics).toContain('DSO')
})

it('includes reminders for each event', () => {
const stemStartDate = new Date('2024-07-15')
const ics = createStemEvaluationICS(stemStartDate)

// Each event should have 3 reminders (30, 7, 1 days)
const alarmCount = (ics!.match(/BEGIN:VALARM/g) || []).length
expect(alarmCount).toBe(12) // 4 events × 3 reminders

// Check for specific reminder periods
expect(ics).toContain('TRIGGER:-P30D') // 30 days before
expect(ics).toContain('TRIGGER:-P7D') // 7 days before
expect(ics).toContain('TRIGGER:-P1D') // 1 day before
})

it('calculates correct dates for milestones', () => {
const stemStartDate = new Date('2024-01-15')
const ics = createStemEvaluationICS(stemStartDate)

// 6 months from Jan 15, 2024 = Jul 15, 2024
expect(ics).toContain('20240715')
// 12 months from Jan 15, 2024 = Jan 15, 2025
expect(ics).toContain('20250115')
// 18 months from Jan 15, 2024 = Jul 15, 2025
expect(ics).toContain('20250715')
// 24 months from Jan 15, 2024 = Jan 15, 2026
expect(ics).toContain('20260115')
})

it('returns null if no start date provided', () => {
const ics = createStemEvaluationICS(null as any)
expect(ics).toBeNull()
})

it('handles leap year dates correctly', () => {
const stemStartDate = new Date('2024-02-29') // Leap year
const ics = createStemEvaluationICS(stemStartDate)

expect(ics).not.toBeNull()
// 6 months from Feb 29, 2024 should be Aug 29, 2024
expect(ics).toContain('20240829')
// 12 months from Feb 29, 2024 should be Feb 28, 2025 (not a leap year)
expect(ics).toContain('20250228')
})
})
54 changes: 50 additions & 4 deletions src/utils/ics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { addDays, format } from 'date-fns'
import { addDays, addMonths, format } from 'date-fns'
import type { EADPeriod } from '../types'
import {
OPT_UNEMPLOYMENT_LIMIT,
Expand All @@ -21,17 +21,35 @@ function generateUid(): string {
return `opt-${Date.now()}-${Math.random().toString(36).slice(2)}`
}

function buildEvent(summary: string, date: Date, description: string): string[] {
return [
function buildEvent(
summary: string,
date: Date,
description: string,
reminders?: number[]
): string[] {
const event = [
'BEGIN:VEVENT',
`UID:${generateUid()}`,
`DTSTAMP:${formatICSDate(new Date())}`,
`DTSTART:${formatICSDate(date)}`,
`DTEND:${formatICSDate(addDays(date, 1))}`,
`SUMMARY:${summary}`,
`DESCRIPTION:${description}`,
'END:VEVENT',
]

// Add reminders/alarms if provided
if (reminders && reminders.length > 0) {
for (const reminderDays of reminders) {
event.push('BEGIN:VALARM')
event.push('ACTION:DISPLAY')
event.push(`DESCRIPTION:${summary}`)
event.push(`TRIGGER:-P${reminderDays}D`)
event.push('END:VALARM')
}
}

event.push('END:VEVENT')
return event
}

export function createUnemploymentICS(
Expand Down Expand Up @@ -81,6 +99,34 @@ export function createUnemploymentICS(
].join('\r\n')
}

/**
* Creates ICS calendar file with STEM OPT evaluation reminders
* Generates events for 6, 12, 18, and 24-month evaluations with reminders
*/
export function createStemEvaluationICS(stemStartDate: Date): string | null {
if (!stemStartDate) return null

const events: string[] = []
const milestones = [6, 12, 18, 24]
const reminders = [30, 7, 1] // 30 days, 7 days, 1 day before

for (const months of milestones) {
const evaluationDate = addMonths(stemStartDate, months)
const summary = `STEM OPT ${months}-Month Evaluation Due`
const description = `This is your STEM OPT ${months}-month evaluation deadline. Please submit Form I-983 evaluation to your DSO before the deadline.`

events.push(...buildEvent(summary, evaluationDate, description, reminders))
}

return [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//OPT Tracker//EN',
...events,
'END:VCALENDAR',
].join('\r\n')
}

export function downloadIcsFile(content: string, filename: string): void {
const blob = new Blob([content], { type: 'text/calendar' })
const url = URL.createObjectURL(blob)
Expand Down