Skip to content

Commit 93b44df

Browse files
committed
added timezone migration, updated intake form frontend and procesor
to store user's timezone
1 parent 10d7876 commit 93b44df

File tree

8 files changed

+189
-6
lines changed

8 files changed

+189
-6
lines changed

backend/app/models/UserData.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class UserData(Base):
5959
ethnic_group = Column(JSON, nullable=True) # Array of strings
6060
marital_status = Column(Text, nullable=True)
6161
has_kids = Column(Text, nullable=True)
62+
timezone = Column(Text, nullable=True)
6263

6364
# Cancer Experience
6465
diagnosis = Column(Text, nullable=True)

backend/app/services/implementations/intake_form_processor.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99

1010
logger = logging.getLogger(__name__)
1111

12+
# Valid Canadian timezone abbreviations
13+
VALID_TIMEZONES = {"NST", "AST", "EST", "CST", "MST", "PST"}
14+
1215

1316
class IntakeFormProcessor:
1417
"""
@@ -177,6 +180,15 @@ def _process_demographics(self, user_data: UserData, demographics: Dict[str, Any
177180
user_data.ethnic_group = demographics.get("ethnic_group", [])
178181
user_data.marital_status = self._trim_text(demographics.get("marital_status"))
179182
user_data.has_kids = demographics.get("has_kids")
183+
184+
# Validate and set timezone
185+
timezone = self._trim_text(demographics.get("timezone"))
186+
if timezone and timezone not in VALID_TIMEZONES:
187+
raise ValueError(
188+
f"Invalid timezone: {timezone}. Must be one of {sorted(VALID_TIMEZONES)}"
189+
)
190+
user_data.timezone = timezone
191+
180192
user_data.other_ethnic_group = self._trim_text(demographics.get("ethnic_group_custom"))
181193
user_data.gender_identity_custom = self._trim_text(demographics.get("gender_identity_custom"))
182194

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"""add_timezone_column_to_user_data
2+
3+
Revision ID: 2ccee7a88d08
4+
Revises: 9f1a6d727929
5+
Create Date: 2025-10-30 19:02:10.801071
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
# revision identifiers, used by Alembic.
14+
revision: str = '2ccee7a88d08'
15+
down_revision: Union[str, None] = '9f1a6d727929'
16+
branch_labels: Union[str, Sequence[str], None] = None
17+
depends_on: Union[str, Sequence[str], None] = None
18+
19+
20+
def upgrade() -> None:
21+
# ### commands auto generated by Alembic - please adjust! ###
22+
op.add_column('user_data', sa.Column('timezone', sa.Text(), nullable=True))
23+
# ### end Alembic commands ###
24+
25+
26+
def downgrade() -> None:
27+
# ### commands auto generated by Alembic - please adjust! ###
28+
op.drop_column('user_data', 'timezone')
29+
# ### end Alembic commands ###

frontend/src/components/intake/demographic-cancer-form.tsx

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { CheckboxGroup } from '@/components/ui/checkbox-group';
77
import { COLORS, VALIDATION } from '@/constants/form';
88
import baseAPIClient from '@/APIClients/baseAPIClient';
99
import { IntakeExperience, IntakeTreatment } from '@/types/intakeTypes';
10+
import { detectCanadianTimezone } from '@/utils/timezoneUtils';
1011

1112
// Reusable Select component to replace inline styling
1213
type StyledSelectProps = React.SelectHTMLAttributes<HTMLSelectElement> & {
@@ -48,23 +49,25 @@ interface DemographicCancerFormData {
4849
ethnicGroup: string[];
4950
maritalStatus: string;
5051
hasKids: string;
52+
timezone: string;
5153
diagnosis: string;
5254
dateOfDiagnosis: string;
5355
treatments: string[];
5456
experiences: string[];
5557
}
5658

57-
const DEFAULT_VALUES: DemographicCancerFormData = {
59+
const getDefaultValues = (): DemographicCancerFormData => ({
5860
genderIdentity: '',
5961
pronouns: [],
6062
ethnicGroup: [],
6163
maritalStatus: '',
6264
hasKids: '',
65+
timezone: detectCanadianTimezone(),
6366
diagnosis: '',
6467
dateOfDiagnosis: '',
6568
treatments: [],
6669
experiences: [],
67-
};
70+
});
6871

6972
const DIAGNOSIS_OPTIONS = [
7073
'Acute Myeloid Leukaemia',
@@ -106,6 +109,8 @@ const PRONOUNS_OPTIONS = [
106109
'Self-describe',
107110
];
108111

112+
const TIMEZONE_OPTIONS = ['NST', 'AST', 'EST', 'CST', 'MST', 'PST'];
113+
109114
const ETHNIC_OPTIONS = [
110115
'Indigenous',
111116
'Arab',
@@ -274,7 +279,7 @@ export function DemographicCancerForm({
274279
caringForSomeone,
275280
}: DemographicCancerFormProps) {
276281
const { control, handleSubmit, formState, watch } = useForm<DemographicCancerFormData>({
277-
defaultValues: DEFAULT_VALUES,
282+
defaultValues: getDefaultValues(),
278283
});
279284
const { errors, isSubmitting } = formState;
280285

@@ -516,6 +521,29 @@ export function DemographicCancerForm({
516521
)}
517522
</HStack>
518523

524+
{/* Time Zone - Left aligned */}
525+
<HStack gap={4} w="full" align="start">
526+
<Box w="50%">
527+
<FormField label="Time Zone" error={errors.timezone?.message}>
528+
<Controller
529+
name="timezone"
530+
control={control}
531+
rules={{ required: 'Time zone is required' }}
532+
render={({ field }) => (
533+
<StyledSelect {...field} error={!!errors.timezone}>
534+
<option value="">Time Zone</option>
535+
{TIMEZONE_OPTIONS.map((tz) => (
536+
<option key={tz} value={tz}>
537+
{tz}
538+
</option>
539+
))}
540+
</StyledSelect>
541+
)}
542+
/>
543+
</FormField>
544+
</Box>
545+
</HStack>
546+
519547
{/* Ethnic or Cultural Group - Left aligned */}
520548
<HStack gap={4} w="full" align="start">
521549
<Box w="50%">
@@ -803,15 +831,17 @@ interface BasicDemographicsFormData {
803831
ethnicGroup: string[];
804832
maritalStatus: string;
805833
hasKids: string;
834+
timezone: string;
806835
}
807836

808-
const BASIC_DEFAULT_VALUES: BasicDemographicsFormData = {
837+
const getBasicDefaultValues = (): BasicDemographicsFormData => ({
809838
genderIdentity: '',
810839
pronouns: [],
811840
ethnicGroup: [],
812841
maritalStatus: '',
813842
hasKids: '',
814-
};
843+
timezone: detectCanadianTimezone(),
844+
});
815845

816846
interface BasicDemographicsFormProps {
817847
formType?: 'participant' | 'volunteer';
@@ -825,7 +855,7 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor
825855
formState: { errors, isSubmitting },
826856
watch,
827857
} = useForm<BasicDemographicsFormData>({
828-
defaultValues: BASIC_DEFAULT_VALUES,
858+
defaultValues: getBasicDefaultValues(),
829859
});
830860

831861
// Local state for custom values
@@ -1020,6 +1050,29 @@ export function BasicDemographicsForm({ formType, onNext }: BasicDemographicsFor
10201050
)}
10211051
</HStack>
10221052

1053+
{/* Time Zone - Left aligned */}
1054+
<HStack gap={4} w="full" align="start">
1055+
<Box w="50%">
1056+
<FormField label="Time Zone" error={errors.timezone?.message}>
1057+
<Controller
1058+
name="timezone"
1059+
control={control}
1060+
rules={{ required: 'Time zone is required' }}
1061+
render={({ field }) => (
1062+
<StyledSelect {...field} error={!!errors.timezone}>
1063+
<option value="">Time Zone</option>
1064+
{TIMEZONE_OPTIONS.map((tz) => (
1065+
<option key={tz} value={tz}>
1066+
{tz}
1067+
</option>
1068+
))}
1069+
</StyledSelect>
1070+
)}
1071+
/>
1072+
</FormField>
1073+
</Box>
1074+
</HStack>
1075+
10231076
{/* Ethnic or Cultural Group */}
10241077
<HStack gap={4} w="full" align="start">
10251078
<Box w="50%">

frontend/src/constants/form.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export interface IntakeFormData {
5858
ethnicGroup: string[];
5959
maritalStatus: string;
6060
hasKids: string;
61+
timezone: string;
6162
};
6263

6364
// User's Cancer Experience (if applicable)
@@ -111,6 +112,7 @@ export interface DemographicsData {
111112
ethnicGroup: string[];
112113
maritalStatus: string;
113114
hasKids: string;
115+
timezone: string;
114116
}
115117

116118
export interface CancerExperienceData {
@@ -149,5 +151,6 @@ export const INITIAL_INTAKE_FORM_DATA: IntakeFormData = {
149151
ethnicGroup: [],
150152
maritalStatus: '',
151153
hasKids: '',
154+
timezone: '',
152155
},
153156
};

frontend/src/pages/participant/intake/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface DemographicCancerFormData {
2727
ethnicGroup: string[];
2828
maritalStatus: string;
2929
hasKids: string;
30+
timezone: string;
3031
diagnosis: string;
3132
dateOfDiagnosis: string;
3233
treatments: string[];
@@ -49,6 +50,7 @@ interface BasicDemographicsFormData {
4950
ethnicGroup: string[];
5051
maritalStatus: string;
5152
hasKids: string;
53+
timezone: string;
5254
}
5355

5456
export default function ParticipantIntakePage() {
@@ -128,6 +130,7 @@ export default function ParticipantIntakePage() {
128130
ethnicGroup: data.ethnicGroup,
129131
maritalStatus: data.maritalStatus,
130132
hasKids: data.hasKids,
133+
timezone: data.timezone,
131134
},
132135
...(prev.hasBloodCancer === 'yes' && {
133136
cancerExperience: {
@@ -184,6 +187,7 @@ export default function ParticipantIntakePage() {
184187
ethnicGroup: data.ethnicGroup,
185188
maritalStatus: data.maritalStatus,
186189
hasKids: data.hasKids,
190+
timezone: data.timezone,
187191
},
188192
};
189193
void advanceAfterUpdate(updated);

frontend/src/pages/volunteer/intake/index.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ interface DemographicCancerFormData {
2727
ethnicGroup: string[];
2828
maritalStatus: string;
2929
hasKids: string;
30+
timezone: string;
3031
diagnosis: string;
3132
dateOfDiagnosis: string;
3233
treatments: string[];
@@ -49,6 +50,7 @@ interface BasicDemographicsFormData {
4950
ethnicGroup: string[];
5051
maritalStatus: string;
5152
hasKids: string;
53+
timezone: string;
5254
}
5355

5456
export default function VolunteerIntakePage() {
@@ -137,6 +139,7 @@ export default function VolunteerIntakePage() {
137139
ethnicGroup: data.ethnicGroup,
138140
maritalStatus: data.maritalStatus,
139141
hasKids: data.hasKids,
142+
timezone: data.timezone,
140143
},
141144
...(prev.hasBloodCancer === 'yes' && {
142145
cancerExperience: {
@@ -193,6 +196,7 @@ export default function VolunteerIntakePage() {
193196
ethnicGroup: data.ethnicGroup,
194197
maritalStatus: data.maritalStatus,
195198
hasKids: data.hasKids,
199+
timezone: data.timezone,
196200
},
197201
};
198202
void advanceAfterUpdate(updated);
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Detects the user's timezone and maps it to Canadian timezone abbreviations.
3+
* Returns one of: NST, AST, EST, CST, MST, PST, or empty string if detection fails.
4+
*/
5+
export function detectCanadianTimezone(): string {
6+
try {
7+
// Use Intl API to get the IANA timezone identifier
8+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
9+
10+
// Map IANA timezone identifiers to Canadian timezone abbreviations
11+
const timezoneMap: Record<string, string> = {
12+
// Newfoundland Standard Time
13+
'America/St_Johns': 'NST',
14+
15+
// Atlantic Standard Time
16+
'America/Halifax': 'AST',
17+
'America/Moncton': 'AST',
18+
'America/Glace_Bay': 'AST',
19+
'America/Goose_Bay': 'AST',
20+
'America/Blanc-Sablon': 'AST',
21+
22+
// Eastern Standard Time
23+
'America/Toronto': 'EST',
24+
'America/Montreal': 'EST',
25+
'America/Ottawa': 'EST',
26+
'America/Thunder_Bay': 'EST',
27+
'America/Nipigon': 'EST',
28+
'America/Rainy_River': 'EST',
29+
'America/Atikokan': 'EST',
30+
31+
// Central Standard Time
32+
'America/Winnipeg': 'CST', // Manitoba uses Central Time
33+
'America/Regina': 'CST',
34+
'America/Swift_Current': 'CST',
35+
36+
// Mountain Standard Time
37+
'America/Edmonton': 'MST',
38+
'America/Calgary': 'MST',
39+
'America/Yellowknife': 'MST',
40+
'America/Inuvik': 'MST',
41+
'America/Cambridge_Bay': 'MST',
42+
'America/Dawson_Creek': 'MST',
43+
'America/Fort_Nelson': 'MST',
44+
45+
// Pacific Standard Time
46+
'America/Vancouver': 'PST',
47+
'America/Whitehorse': 'PST',
48+
'America/Dawson': 'PST',
49+
};
50+
51+
// Check if we have a direct mapping
52+
if (timezoneMap[timeZone]) {
53+
return timezoneMap[timeZone];
54+
}
55+
56+
// Fallback: Use timezone offset to estimate
57+
// Get UTC offset in hours (accounting for DST)
58+
const now = new Date();
59+
const offsetMinutes = now.getTimezoneOffset();
60+
const offsetHours = -offsetMinutes / 60; // Invert because getTimezoneOffset returns opposite sign
61+
62+
// Map UTC offsets to Canadian timezones (accounting for DST)
63+
// During DST, offsets are shifted by 1 hour, so we map to standard time equivalents
64+
if (offsetHours === -3.5 || offsetHours === -2.5) return 'NST'; // Newfoundland (standard or daylight)
65+
if (offsetHours === -4 || offsetHours === -3) return 'AST'; // Atlantic (standard or daylight)
66+
if (offsetHours === -5 || offsetHours === -4) return 'EST'; // Eastern (standard or daylight)
67+
if (offsetHours === -6 || offsetHours === -5) return 'CST'; // Central (standard or daylight)
68+
if (offsetHours === -7 || offsetHours === -6) return 'MST'; // Mountain (standard or daylight)
69+
if (offsetHours === -8 || offsetHours === -7) return 'PST'; // Pacific (standard or daylight)
70+
71+
return '';
72+
} catch (error) {
73+
console.warn('Unable to detect timezone:', error);
74+
return '';
75+
}
76+
}
77+

0 commit comments

Comments
 (0)